From 71752606e1c665e8dc009b1549c9888a154e50b3 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Thu, 5 May 2022 19:43:46 -0700 Subject: [PATCH] Automatically save generated passwords (#81) Task/Issue URL: https://app.asana.com/0/0/1201969773101195/f iOS PR: duckduckgo/iOS#1109 macOS PR: more-duckduckgo-org/macos-browser#500 Description: This PR updates BSK to automatically save generated credentials when the user selects that option. Note that this PR does not require any changes on either of the client codebases. If the user doesn't have a username entered, they will get prompted immediately. If they have already entered a username, the credential gets saved without prompting them. See parent task for context. --- .swiftlint.yml | 3 +- .../AutofillUserScript+SecureVault.swift | 21 +- .../ContentBlockingRulesCompilationTask.swift | 1 - .../Resources/duckduckgo-autofill | 2 +- .../SecureVault/SecureVaultManager.swift | 149 +++++++++-- .../SecureVault/MockVaultProviders.swift | 82 ++++-- .../SecureVault/SecureVaultManagerTests.swift | 239 ++++++++++++++++++ .../SecureVault/SecureVaultTests.swift | 47 ++-- 8 files changed, 476 insertions(+), 68 deletions(-) create mode 100644 Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index bb60ab08b..f66182e12 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,6 @@ disabled_rules: - trailing_whitespace + - nesting line_length: warning: 150 @@ -18,4 +19,4 @@ type_name: error: 100 excluded: - - .build \ No newline at end of file + - .build diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift index 621f31d16..b67a2e4a4 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift @@ -134,30 +134,35 @@ extension AutofillUserScript { // MARK: - Requests - public struct IncomingCredentials { - + public struct IncomingCredentials: Equatable { + private enum Constants { static let credentialsKey = "credentials" static let usernameKey = "username" static let passwordKey = "password" + static let autogeneratedKey = "autogenerated" } let username: String? let password: String + let autogenerated: Bool - init(username: String?, password: String) { + init(username: String?, password: String, autogenerated: Bool = false) { self.username = username self.password = password + self.autogenerated = autogenerated } init?(autofillDictionary: [String: Any]) { - guard let credentialsDictionary = autofillDictionary[Constants.credentialsKey] as? [String: String], - let password = credentialsDictionary[Constants.passwordKey] else { + guard let credentialsDictionary = autofillDictionary[Constants.credentialsKey] as? [String: Any], + let password = credentialsDictionary[Constants.passwordKey] as? String else { return nil } // Usernames are optional, as the Autofill script can pass a generated password through without a corresponding username. - self.init(username: credentialsDictionary[Constants.usernameKey], password: password) + self.init(username: credentialsDictionary[Constants.usernameKey] as? String, + password: password, + autogenerated: (credentialsDictionary[Constants.autogeneratedKey] as? Bool) ?? false) } } @@ -172,6 +177,10 @@ extension AutofillUserScript { public let credentials: IncomingCredentials? public let creditCard: SecureVaultModels.CreditCard? + var hasAutogeneratedPassword: Bool { + return credentials?.autogenerated ?? false + } + init(dictionary: [String: Any]) { self.identity = .init(autofillDictionary: dictionary) self.creditCard = .init(autofillDictionary: dictionary) diff --git a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockingRulesCompilationTask.swift b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockingRulesCompilationTask.swift index 9b91d8551..6a6f1501e 100644 --- a/Sources/BrowserServicesKit/ContentBlocking/ContentBlockingRulesCompilationTask.swift +++ b/Sources/BrowserServicesKit/ContentBlocking/ContentBlockingRulesCompilationTask.swift @@ -28,7 +28,6 @@ extension ContentBlockerRulesManager { Encapsulates compilation steps for a single Task */ class CompilationTask { - // swiftlint:disable:next nesting typealias Completion = (_ success: Bool) -> Void let workQueue: DispatchQueue let rulesList: ContentBlockerRulesList diff --git a/Sources/BrowserServicesKit/Resources/duckduckgo-autofill b/Sources/BrowserServicesKit/Resources/duckduckgo-autofill index 4df6b625a..dd9165ab9 160000 --- a/Sources/BrowserServicesKit/Resources/duckduckgo-autofill +++ b/Sources/BrowserServicesKit/Resources/duckduckgo-autofill @@ -1 +1 @@ -Subproject commit 4df6b625a41d909793a1b89867d767e74477549f +Subproject commit dd9165ab903eb22894a67bafaa7365a31e208906 diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift index ddec65a7d..e1872eda7 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift @@ -31,6 +31,7 @@ public struct AutofillData { public let identity: SecureVaultModels.Identity? public let credentials: SecureVaultModels.WebsiteCredentials? public let creditCard: SecureVaultModels.CreditCard? + public let automaticallySavedCredentials: Bool } public protocol SecureVaultManagerDelegate: SecureVaultErrorReporting { @@ -39,14 +40,20 @@ public protocol SecureVaultManagerDelegate: SecureVaultErrorReporting { func secureVaultManager(_: SecureVaultManager, didAutofill type: AutofillType, withObjectId objectId: Int64) + // swiftlint:disable:next identifier_name func secureVaultManager(_: SecureVaultManager, didRequestAuthenticationWithCompletionHandler: @escaping (Bool) -> Void) + } public class SecureVaultManager { public weak var delegate: SecureVaultManagerDelegate? + + private let vault: SecureVault? - public init() { } + public init(vault: SecureVault? = nil) { + self.vault = vault + } } @@ -60,7 +67,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { [SecureVaultModels.CreditCard]) -> Void) { do { - let vault = try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) let accounts = try vault.accountsFor(domain: domain) let identities = try vault.identities() let cards = try vault.creditCards() @@ -79,9 +86,14 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { /// Receives each of the types of data that the Autofill script has detected, and determines whether the user should be prompted to save them. /// This involves checking each proposed object to determine whether it already exists in the store. /// Currently, only one new type of data is presented to the user, but that decision is handled client-side so that it's easier to adapt in the future when multiple types are presented at once. - public func autofillUserScript(_: AutofillUserScript, didRequestStoreDataForDomain domain: String, data: AutofillUserScript.DetectedAutofillData) { + public func autofillUserScript(_: AutofillUserScript, + didRequestStoreDataForDomain domain: String, + data: AutofillUserScript.DetectedAutofillData) { do { - let dataToPrompt = try existingEntries(for: domain, autofillData: data) + let automaticallySavedCredentials = try storeOrUpdateAutogeneratedCredentials(domain: domain, autofillData: data) + try updateExistingAutogeneratedCredentialsWithSubmittedValues(domain: domain, autofillData: data) + + let dataToPrompt = try existingEntries(for: domain, autofillData: data, automaticallySavedCredentials: automaticallySavedCredentials) delegate?.secureVaultManager(self, promptUserToStoreAutofillData: dataToPrompt) } catch { os_log(.error, "Error storing data: %{public}@", error.localizedDescription) @@ -93,8 +105,8 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { completionHandler: @escaping ([SecureVaultModels.WebsiteAccount]) -> Void) { do { - completionHandler(try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) - .accountsFor(domain: domain)) + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + completionHandler(try vault.accountsFor(domain: domain)) } catch { os_log(.error, "Error requesting accounts: %{public}@", error.localizedDescription) completionHandler([]) @@ -107,8 +119,8 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { completionHandler: @escaping (SecureVaultModels.WebsiteCredentials?) -> Void) { do { - completionHandler(try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) - .websiteCredentialsFor(accountId: accountId)) + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + completionHandler(try vault.websiteCredentialsFor(accountId: accountId)) delegate?.secureVaultManager(self, didAutofill: .password, withObjectId: accountId) } catch { os_log(.error, "Error requesting credentials: %{public}@", error.localizedDescription) @@ -121,7 +133,8 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { didRequestCreditCardWithId creditCardId: Int64, completionHandler: @escaping (SecureVaultModels.CreditCard?) -> Void) { do { - let card = try SecureVaultFactory.default.makeVault(errorReporter: self.delegate).creditCardFor(id: creditCardId) + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + let card = try vault.creditCardFor(id: creditCardId) delegate?.secureVaultManager(self, didRequestAuthenticationWithCompletionHandler: { authenticated in if authenticated { @@ -142,8 +155,9 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { didRequestIdentityWithId identityId: Int64, completionHandler: @escaping (SecureVaultModels.Identity?) -> Void) { do { - completionHandler(try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) - .identityFor(id: identityId)) + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + completionHandler(try vault.identityFor(id: identityId)) + delegate?.secureVaultManager(self, didAutofill: .identity, withObjectId: identityId) } catch { os_log(.error, "Error requesting identity: %{public}@", error.localizedDescription) @@ -151,14 +165,105 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { } } - func existingEntries(for domain: String, autofillData: AutofillUserScript.DetectedAutofillData) throws -> AutofillData { - let vault = try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + /// Stores autogenerated credentials sent by the AutofillUserScript, or updates an existing row in the database if credentials already exist. + /// The Secure Vault only stores one generated password for a domain, which is updated any time the user selects a new generated password. + func storeOrUpdateAutogeneratedCredentials(domain: String, autofillData: AutofillUserScript.DetectedAutofillData) throws -> Bool { + guard autofillData.hasAutogeneratedPassword, + let autogeneratedCredentials = autofillData.credentials, + !(autogeneratedCredentials.username?.isEmpty ?? true), + let passwordData = autogeneratedCredentials.password.data(using: .utf8) else { + os_log("Did not meet conditions for silently saving autogenerated credentials, returning early", log: .passwordManager) + return false + } + + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + let accounts = try vault.accountsFor(domain: domain) + + if accounts.contains(where: { account in account.username == autogeneratedCredentials.username }) { + os_log("Tried to automatically save credentials for which an account already exists, returning early", log: .passwordManager) + return false + } + + // As a precaution, check whether an account exists with the matching generated password _and_ a non-nil username. + // If so, then the user must have already saved the generated credentials and set a username. + + for account in accounts where !account.username.isEmpty { + if let accountID = account.id, + let credentialsForAccount = try vault.websiteCredentialsFor(accountId: accountID), + credentialsForAccount.password == passwordData, + account.username == autogeneratedCredentials.username ?? "" { + os_log("Tried to save autogenerated password but it already exists, returning early", log: .passwordManager) + return false + } + } + + let existingAccount = accounts.first(where: { $0.username == "" }) + var account = existingAccount ?? SecureVaultModels.WebsiteAccount(username: "", domain: domain) + + account.title = "Saved Password (\(domain))" + let generatedPassword = SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) + + os_log("Saving autogenerated password", log: .passwordManager) + + try vault.storeWebsiteCredentials(generatedPassword) + + return true + } + + /// If credentials are sent via the AutofillUserScript, and there exists a credential row with empty username and matching password, then this function will update that credential row with the username. + /// This can happen if the user chooses to use a generated password when signing up for a service, then enters their own username and submits the form. + func updateExistingAutogeneratedCredentialsWithSubmittedValues(domain: String, autofillData: AutofillUserScript.DetectedAutofillData) throws { + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + let accounts = try vault.accountsFor(domain: domain) + + guard let autofillCredentials = autofillData.credentials, + let autofillCredentialsUsername = autofillCredentials.username, + var existingAccount = accounts.first(where: { $0.username == "" }), + let existingAccountID = existingAccount.id else { + return + } + + if accounts.contains(where: { $0.username == autofillCredentials.username }) { + os_log("ERROR: Tried to save generated credentials with an existing username, prompt the user to update instead.", log: .passwordManager) + return + } + + let existingCredentials = try vault.websiteCredentialsFor(accountId: existingAccountID) + + guard let existingPasswordData = existingCredentials?.password, + let autofillPasswordData = autofillCredentials.password.data(using: .utf8) else { + return + } + + // If true, then the existing generated password matches the credentials sent by the script, so update and save the difference. + if existingPasswordData == autofillPasswordData { + os_log("Found matching autogenerated credentials in Secure Vault, updating with username", log: .passwordManager) + + existingAccount.username = autofillCredentialsUsername + existingAccount.title = nil // Remove the "Saved Password" title so that the UI uses the default title format + + let credentialsToSave = SecureVaultModels.WebsiteCredentials(account: existingAccount, password: autofillPasswordData) + + try vault.storeWebsiteCredentials(credentialsToSave) + } + } + + func existingEntries(for domain: String, + autofillData: AutofillUserScript.DetectedAutofillData, + automaticallySavedCredentials: Bool) throws -> AutofillData { + let vault = try self.vault ?? SecureVaultFactory.default.makeVault(errorReporter: self.delegate) let proposedIdentity = try existingIdentity(with: autofillData, vault: vault) - let proposedCredentials = try existingCredentials(with: autofillData, domain: domain, vault: vault) + let proposedCredentials = try existingCredentials(with: autofillData, + domain: domain, + automaticallySavedCredentials: automaticallySavedCredentials, + vault: vault) let proposedCard = try existingPaymentMethod(with: autofillData, vault: vault) - return AutofillData(identity: proposedIdentity, credentials: proposedCredentials, creditCard: proposedCard) + return AutofillData(identity: proposedIdentity, + credentials: proposedCredentials, + creditCard: proposedCard, + automaticallySavedCredentials: automaticallySavedCredentials) } private func existingIdentity(with autofillData: AutofillUserScript.DetectedAutofillData, @@ -174,17 +279,23 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { private func existingCredentials(with autofillData: AutofillUserScript.DetectedAutofillData, domain: String, + automaticallySavedCredentials: Bool, vault: SecureVault) throws -> SecureVaultModels.WebsiteCredentials? { if let credentials = autofillData.credentials, let passwordData = credentials.password.data(using: .utf8) { if let account = try vault .accountsFor(domain: domain) - .first(where: { $0.username == credentials.username }) { + .first(where: { $0.username == credentials.username ?? "" }) { if let existingAccountID = account.id, let existingCredentials = try vault.websiteCredentialsFor(accountId: existingAccountID), existingCredentials.password == passwordData { - os_log("Found duplicate credentials, avoid prompting user", log: .passwordManager) - return nil + if automaticallySavedCredentials { + os_log("Found duplicate credentials which were just saved, notifying user", log: .passwordManager) + return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) + } else { + os_log("Found duplicate credentials which were previously saved, avoid notifying user", log: .passwordManager) + return nil + } } else { os_log("Found existing credentials to update", log: .passwordManager) return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) @@ -203,7 +314,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { } private func existingPaymentMethod(with autofillData: AutofillUserScript.DetectedAutofillData, - vault: SecureVault) throws -> SecureVaultModels.CreditCard? { + vault: SecureVault) throws -> SecureVaultModels.CreditCard? { if let card = autofillData.creditCard, try vault.existingCardForAutofill(matching: card) == nil { os_log("Got new payment method to save", log: .passwordManager) return card diff --git a/Tests/BrowserServicesKitTests/SecureVault/MockVaultProviders.swift b/Tests/BrowserServicesKitTests/SecureVault/MockVaultProviders.swift index dfcd787bd..b65f62c9a 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/MockVaultProviders.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/MockVaultProviders.swift @@ -22,25 +22,27 @@ import Foundation internal class MockDatabaseProvider: SecureVaultDatabaseProvider { // swiftlint:disable identifier_name - var _accounts = [SecureVaultModels.WebsiteAccount]() - var _notes = [SecureVaultModels.Note]() - var _identities = [SecureVaultModels.Identity]() - var _creditCards = [SecureVaultModels.CreditCard]() + var _accounts = [SecureVaultModels.WebsiteAccount]() + var _notes = [SecureVaultModels.Note]() + var _identities = [Int64: SecureVaultModels.Identity]() + var _creditCards = [Int64: SecureVaultModels.CreditCard]() var _forDomain = [String]() - var _credentials: SecureVaultModels.WebsiteCredentials? - var _lastCredentials: SecureVaultModels.WebsiteCredentials? + var _credentialsDict = [Int64: SecureVaultModels.WebsiteCredentials]() var _note: SecureVaultModels.Note? - var _identity: SecureVaultModels.Identity? - var _creditCard: SecureVaultModels.CreditCard? // swiftlint:enable identifier_name func storeWebsiteCredentials(_ credentials: SecureVaultModels.WebsiteCredentials) throws -> Int64 { - _lastCredentials = credentials - return _lastCredentials?.account.id ?? -1 + if let accountID = credentials.account.id { + _credentialsDict[accountID] = credentials + return accountID + } else { + _credentialsDict[-1] = credentials + return -1 + } } func websiteCredentialsForAccountId(_ accountId: Int64) throws -> SecureVaultModels.WebsiteCredentials? { - return _credentials + return _credentialsDict[accountId] } func websiteAccountsForDomain(_ domain: String) throws -> [SecureVaultModels.WebsiteAccount] { @@ -74,37 +76,45 @@ internal class MockDatabaseProvider: SecureVaultDatabaseProvider { } func identities() throws -> [SecureVaultModels.Identity] { - return _identities + return Array(_identities.values) } func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? { - return _identity + return _identities[identityId] } func storeIdentity(_ identity: SecureVaultModels.Identity) throws -> Int64 { - _identity = identity - return identity.id ?? -1 + if let identityID = identity.id { + _identities[identityID] = identity + return identityID + } else { + return -1 + } } func deleteIdentityForIdentityId(_ identityId: Int64) throws { - self._identities = self._identities.filter { $0.id != identityId } + _identities.removeValue(forKey: identityId) } func creditCards() throws -> [SecureVaultModels.CreditCard] { - return _creditCards + return Array(_creditCards.values) } func creditCardForCardId(_ cardId: Int64) throws -> SecureVaultModels.CreditCard? { - return _creditCard + return _creditCards[cardId] } func storeCreditCard(_ creditCard: SecureVaultModels.CreditCard) throws -> Int64 { - _creditCard = creditCard - return creditCard.id ?? -1 + if let cardID = creditCard.id { + _creditCards[cardID] = creditCard + return cardID + } else { + return -1 + } } func deleteCreditCardForCreditCardId(_ cardId: Int64) throws { - self._creditCards = self._creditCards.filter { $0.id != cardId } + _creditCards.removeValue(forKey: cardId) } } @@ -137,7 +147,7 @@ internal class MockCryptoProvider: SecureVaultCryptoProvider { func encrypt(_ data: Data, withKey key: Data) throws -> Data { _lastDataToEncrypt = data _lastKey = key - return Data() + return data } func decrypt(_ data: Data, withKey key: Data) throws -> Data { @@ -153,6 +163,34 @@ internal class MockCryptoProvider: SecureVaultCryptoProvider { } +internal class NoOpCryptoProvider: SecureVaultCryptoProvider { + + func generateSecretKey() throws -> Data { + return Data() + } + + func generatePassword() throws -> Data { + return Data() + } + + func deriveKeyFromPassword(_ password: Data) throws -> Data { + return password + } + + func generateNonce() throws -> Data { + return Data() + } + + func encrypt(_ data: Data, withKey key: Data) throws -> Data { + return data + } + + func decrypt(_ data: Data, withKey key: Data) throws -> Data { + return data + } + +} + internal class MockKeystoreProvider: SecureVaultKeyStoreProvider { // swiftlint:disable identifier_name diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift new file mode 100644 index 000000000..eda7372a6 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultManagerTests.swift @@ -0,0 +1,239 @@ +// +// SecureVaultManagerTests.swift +// +// Copyright © 2022 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 + +class SecureVaultManagerTests: XCTestCase { + + private var mockCryptoProvider = NoOpCryptoProvider() + private var mockDatabaseProvider = MockDatabaseProvider() + private var mockKeystoreProvider = MockKeystoreProvider() + + private let mockAutofillUserScript: AutofillUserScript = { + let embeddedConfig = + """ + { + "features": { + "autofill": { + "status": "enabled", + "exceptions": [] + } + }, + "unprotectedTemporary": [] + } + """.data(using: .utf8)! + let privacyConfig = AutofillTestHelper.preparePrivacyConfig(embeddedConfig: embeddedConfig) + let properties = ContentScopeProperties(gpcEnabled: false, sessionKey: "1234") + let sourceProvider = DefaultAutofillSourceProvider(privacyConfigurationManager: privacyConfig, + properties: properties) + return AutofillUserScript(scriptSourceProvider: sourceProvider, encrypter: MockEncrypter(), hostProvider: SecurityOriginHostProvider()) + }() + + private var testVault: SecureVault! + private var secureVaultManagerDelegate: MockSecureVaultManagerDelegate! + private var manager: SecureVaultManager! + + override func setUp() { + super.setUp() + + mockKeystoreProvider._generatedPassword = "generated".data(using: .utf8) + mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8) + + let providers = SecureVaultProviders(crypto: mockCryptoProvider, database: mockDatabaseProvider, keystore: mockKeystoreProvider) + + self.testVault = DefaultSecureVault(authExpiry: 30, providers: providers) + self.secureVaultManagerDelegate = MockSecureVaultManagerDelegate() + self.manager = SecureVaultManager(vault: self.testVault) + self.manager.delegate = secureVaultManagerDelegate + } + + func testWhenGettingExistingEntries_AndNoAutofillDataWasProvided_AndNoEntriesExist_ThenReturnValueIsNil() throws { + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: nil, creditCard: nil) + let entries = try manager.existingEntries(for: "domain.com", autofillData: autofillData, automaticallySavedCredentials: false) + + XCTAssertNil(entries.credentials) + XCTAssertNil(entries.identity) + XCTAssertNil(entries.creditCard) + } + + func testWhenGettingExistingEntries_AndAutofillCreditCardWasProvided_AndNoMatchingCreditCardExists_ThenReturnValueIncludesCard() throws { + let card = paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 2022) + + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: nil, creditCard: card) + let entries = try manager.existingEntries(for: "domain.com", autofillData: autofillData, automaticallySavedCredentials: false) + + XCTAssertNil(entries.credentials) + XCTAssertNil(entries.identity) + XCTAssertNotNil(entries.creditCard) + XCTAssertTrue(entries.creditCard!.hasAutofillEquality(comparedTo: card)) + } + + func testWhenGettingExistingEntries_AndAutofillCreditCardWasProvided_AndMatchingCreditCardExists_ThenReturnValueIsNil() throws { + let card = paymentMethod(id: 1, cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 2022) + try self.testVault.storeCreditCard(card) + + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: nil, creditCard: card) + let entries = try manager.existingEntries(for: "domain.com", autofillData: autofillData, automaticallySavedCredentials: false) + + XCTAssertNil(entries.credentials) + XCTAssertNil(entries.identity) + XCTAssertNil(entries.creditCard) + } + + func testWhenGettingExistingEntries_AndAutofillIdentityWasProvided_AndNoMatchingIdentityExists_ThenReturnValueIncludesIdentity() throws { + let identity = identity(name: ("First", "Middle", "Last"), addressStreet: "Address Street") + + let autofillData = AutofillUserScript.DetectedAutofillData(identity: identity, credentials: nil, creditCard: nil) + let entries = try manager.existingEntries(for: "domain.com", autofillData: autofillData, automaticallySavedCredentials: false) + + XCTAssertNil(entries.credentials) + XCTAssertNil(entries.creditCard) + XCTAssertNotNil(entries.identity) + XCTAssertTrue(entries.identity!.hasAutofillEquality(comparedTo: identity)) + } + + func testWhenGettingExistingEntries_AndAutofillIdentityWasProvided_AndMatchingIdentityExists_ThenReturnValueIsNil() throws { + let identity = identity(id: 1, name: ("First", "Middle", "Last"), addressStreet: "Address Street") + try self.testVault.storeIdentity(identity) + + let autofillData = AutofillUserScript.DetectedAutofillData(identity: identity, credentials: nil, creditCard: nil) + let entries = try manager.existingEntries(for: "domain.com", autofillData: autofillData, automaticallySavedCredentials: false) + + XCTAssertNil(entries.credentials) + XCTAssertNil(entries.identity) + XCTAssertNil(entries.creditCard) + } + + // MARK: - AutofillSecureVaultDelegate Tests + + func testWhenRequestingToStoreCredentials_AndCredentialsDoNotExist_ThenTheDelegateIsPromptedToStoreAutofillData() { + let incomingCredentials = AutofillUserScript.IncomingCredentials(username: "username", password: "password", autogenerated: false) + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil) + + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData) + manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "domain.com", data: autofillData) + XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData) + XCTAssertEqual(incomingCredentials, autofillData.credentials) + } + + func testWhenRequestingToStoreCredentials_AndCredentialsAreGenerated_AndNoCredentialsAlreadyExist_ThenTheDelegateIsPromptedToStoreAutofillData() throws { + let incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "password", autogenerated: true) + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil) + + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData) + manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "domain.com", data: autofillData) + XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData) + + XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.username, "") + XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.domain, "domain.com") + XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.password, "password".data(using: .utf8)!) + } + + func testWhenRequestingToStoreCredentials_AndCredentialsAreAutoGenerated_AndCredentialsAlreadyExist_ThenPromptedAutofillDataIsEmpty() throws { + let domain = "domain.com" + let account = SecureVaultModels.WebsiteAccount(id: 1, title: nil, username: "", domain: domain, created: Date(), lastUpdated: Date()) + self.mockDatabaseProvider._accounts = [account] + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: "password".data(using: .utf8)!) + try self.testVault.storeWebsiteCredentials(credentials) + + let incomingCredentials = AutofillUserScript.IncomingCredentials(username: "", password: "password", autogenerated: true) + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil) + + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData) + manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: domain, data: autofillData) + XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.credentials) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.creditCard) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.identity) + } + + func testWhenRequestingToStoreCredentials_AndCredentialsAreNotAutoGenerated_AndCredentialsAlreadyExist_ThenPromptedAutofillDataIsEmpty() throws { + let domain = "domain.com" + let account = SecureVaultModels.WebsiteAccount(id: 1, title: nil, username: "username", domain: domain, created: Date(), lastUpdated: Date()) + self.mockDatabaseProvider._accounts = [account] + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: "password".data(using: .utf8)!) + try self.testVault.storeWebsiteCredentials(credentials) + + let incomingCredentials = AutofillUserScript.IncomingCredentials(username: "username", password: "password", autogenerated: false) + let autofillData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil) + + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData) + manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: domain, data: autofillData) + XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.credentials) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.creditCard) + XCTAssertNil(secureVaultManagerDelegate.promptedAutofillData?.identity) + } + + // MARK: - Test Utilities + + private func identity(id: Int64? = nil, name: (String, String, String), addressStreet: String?) -> SecureVaultModels.Identity { + return SecureVaultModels.Identity(id: id, + title: nil, + created: Date(), + lastUpdated: Date(), + firstName: name.0, + middleName: name.1, + lastName: name.2, + birthdayDay: nil, + birthdayMonth: nil, + birthdayYear: nil, + addressStreet: addressStreet, + addressStreet2: nil, + addressCity: nil, + addressProvince: nil, + addressPostalCode: nil, + addressCountryCode: nil, + homePhone: nil, + mobilePhone: nil, + emailAddress: nil) + } + + private func paymentMethod(id: Int64? = nil, + cardNumber: String, + cardholderName: String, + cvv: String, + month: Int, + year: Int) -> SecureVaultModels.CreditCard { + return SecureVaultModels.CreditCard(id: id, + title: nil, + cardNumber: cardNumber, + cardholderName: cardholderName, + cardSecurityCode: cvv, + expirationMonth: month, + expirationYear: year) + } + +} + +private class MockSecureVaultManagerDelegate: SecureVaultManagerDelegate { + + private(set) var promptedAutofillData: AutofillData? + + func secureVaultManager(_: SecureVaultManager, promptUserToStoreAutofillData data: AutofillData) { + self.promptedAutofillData = data + } + + func secureVaultManager(_: SecureVaultManager, didAutofill type: AutofillType, withObjectId objectId: Int64) {} + + func secureVaultManager(_: SecureVaultManager, didRequestAuthenticationWithCompletionHandler: @escaping (Bool) -> Void) {} + + func secureVaultInitFailed(_ error: SecureVaultError) {} + +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift index d9127e926..e0e151295 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultTests.swift @@ -73,13 +73,20 @@ class SecureVaultTests: XCTestCase { } func testWhenDeletingCredentialsForAccount_ThenDatabaseCalled() throws { + mockKeystoreProvider._generatedPassword = "generated".data(using: .utf8)! + mockCryptoProvider._derivedKey = "derived".data(using: .utf8)! + mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8)! + mockCryptoProvider._decryptedData = "decrypted".data(using: .utf8)! + let account = SecureVaultModels.WebsiteAccount(id: 1, title: "Title", username: "test@duck.com", domain: "example.com", created: Date(), lastUpdated: Date()) - mockDatabaseProvider._credentials = SecureVaultModels.WebsiteCredentials(account: account, password: "password".data(using: .utf8)!) + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: "password".data(using: .utf8)!) + + try testVault.storeWebsiteCredentials(credentials) mockDatabaseProvider._accounts = [account] XCTAssertEqual("example.com", mockDatabaseProvider._accounts[0].domain) @@ -134,7 +141,6 @@ class SecureVaultTests: XCTestCase { } func testWhenStoringWebsiteCredentials_ThenThePasswordIsEncryptedWithL2Key() throws { - mockKeystoreProvider._generatedPassword = "generated".data(using: .utf8)! mockCryptoProvider._derivedKey = "derived".data(using: .utf8)! mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8)! @@ -147,24 +153,27 @@ class SecureVaultTests: XCTestCase { try testVault.storeWebsiteCredentials(credentials) - XCTAssertNotNil(mockDatabaseProvider._lastCredentials) + XCTAssertNotNil(mockDatabaseProvider._credentialsDict.first) XCTAssertEqual(mockCryptoProvider._lastDataToEncrypt, passwordToEncrypt) } func testWhenCredentialsAreRetrievedUsingGeneratedPassword_ThenTheyAreDecrypted() throws { - let password = "password".data(using: .utf8)! - let account = SecureVaultModels.WebsiteAccount(username: "test@duck.com", domain: "example.com") - mockDatabaseProvider._credentials = SecureVaultModels.WebsiteCredentials(account: account, password: password) + let account = SecureVaultModels.WebsiteAccount(id: 1, username: "test@duck.com", domain: "example.com", created: Date(), lastUpdated: Date()) + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: password) + self.mockDatabaseProvider._accounts = [account] + mockCryptoProvider._decryptedData = "decrypted".data(using: .utf8) mockKeystoreProvider._generatedPassword = "generated".data(using: .utf8) mockCryptoProvider._derivedKey = "derived".data(using: .utf8) mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8) - - let credentials = try testVault.websiteCredentialsFor(accountId: 1) - XCTAssertNotNil(credentials) - XCTAssertNotNil(credentials?.password) + + try testVault.storeWebsiteCredentials(credentials) + + let fetchedCredentials = try testVault.websiteCredentialsFor(accountId: 1) + XCTAssertNotNil(fetchedCredentials) + XCTAssertNotNil(fetchedCredentials?.password) XCTAssertEqual(mockCryptoProvider._lastDataToDecrypt, password) @@ -173,25 +182,27 @@ class SecureVaultTests: XCTestCase { func testWhenCredentialsAreRetrievedUsingUserPassword_ThenTheyAreDecrypted() throws { let userPassword = "userPassword".data(using: .utf8)! let password = "password".data(using: .utf8)! - let account = SecureVaultModels.WebsiteAccount(username: "test@duck.com", domain: "example.com") - mockDatabaseProvider._credentials = SecureVaultModels.WebsiteCredentials(account: account, password: password) + let account = SecureVaultModels.WebsiteAccount(id: 1, username: "test@duck.com", domain: "example.com", created: Date(), lastUpdated: Date()) + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: password) + self.mockDatabaseProvider._accounts = [account] + mockCryptoProvider._decryptedData = "decrypted".data(using: .utf8) mockCryptoProvider._derivedKey = "derived".data(using: .utf8) mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8) + + _ = try testVault.authWith(password: userPassword) + try testVault.storeWebsiteCredentials(credentials) - let credentials = try testVault.authWith(password: userPassword).websiteCredentialsFor(accountId: 1) + let fetchedCredentials = try testVault.authWith(password: userPassword).websiteCredentialsFor(accountId: 1) - XCTAssertNotNil(credentials) - XCTAssertNotNil(credentials?.password) + XCTAssertNotNil(fetchedCredentials) + XCTAssertNotNil(fetchedCredentials?.password) XCTAssertEqual(mockCryptoProvider._lastDataToDecrypt, password) } func testWhenCredentialsAreRetrievedUsingExpiredUserPassword_ThenErrorIsThrown() throws { let userPassword = "userPassword".data(using: .utf8)! - let password = "password".data(using: .utf8)! - let account = SecureVaultModels.WebsiteAccount(username: "test@duck.com", domain: "example.com") - mockDatabaseProvider._credentials = SecureVaultModels.WebsiteCredentials(account: account, password: password) mockCryptoProvider._decryptedData = "decrypted".data(using: .utf8) mockCryptoProvider._derivedKey = "derived".data(using: .utf8) mockKeystoreProvider._encryptedL2Key = "encryptedL2Key".data(using: .utf8)