Skip to content

Commit

Permalink
Increase ratio of complete form saves (#1124)
Browse files Browse the repository at this point in the history
Please review the release process for BrowserServicesKit
[here](https://app.asana.com/0/1200194497630846/1200837094583426).

**Required**:

Task/Issue URL: https://app.asana.com/0/0/1206048666874235/f
iOS PR: duckduckgo/iOS#3698
macOS PR: duckduckgo/macos-browser#3649
What kind of version bump will this require?: Major

**Description**:

Increase ratio of complete credentials saves (i.e. that include the
username) by checking for recently filled emails/usernames when a form
save event is incomplete.

Approach is to have a new form save events for username/email-only forms
that stays in memory for 3 minutes. If a password-only form save is
detected for the same domain, we can complete the form with the previous
partial data. This would also cover mobile where we don't have
identities autofill – and in general in all cases where the user inputs
data manually.

**Steps to test this PR**:

https://app.asana.com/0/1202926619870900/1208866651723703/f


**OS Testing**:

* [ ] iOS 14
* [ ] iOS 15
* [ ] iOS 16
* [ ] macOS 10.15
* [ ] macOS 11
* [ ] macOS 12

---
###### Internal references:
[Software Engineering
Expectations](https://app.asana.com/0/59792373528535/199064865822552)
[Technical Design
Template](https://app.asana.com/0/59792373528535/184709971311943)
  • Loading branch information
graeme authored Dec 9, 2024
1 parent cbfeb62 commit 20df9e2
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,15 @@
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PhishingDetectionTests"
BuildableName = "PhishingDetectionTests"
BlueprintName = "PhishingDetectionTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PhishingDetectionTests"
BuildableName = "PhishingDetectionTests"
BlueprintName = "PhishingDetectionTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ extension AutofillUserScript {
case userInitiated
case autoprompt
case formSubmission
case partialSave
case passwordGeneration
case emailProtection
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public enum AutofillSubfeature: String, PrivacySubfeature {
case onForExistingUsers
case unknownUsernameCategorization
case credentialsImportPromotionForExistingUsers
case partialFormSaves
}

public enum DBPSubfeature: String, Equatable, PrivacySubfeature {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// PartialFormSaveManager.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 Common

final class PartialFormSaveManager {
typealias WebsiteAccount = SecureVaultModels.WebsiteAccount

private static var accounts: [String: WebsiteAccount] = .init()

private let tld: TLD

init(tld: TLD) {
self.tld = tld
}

func partialAccount(forDomain domain: String) -> WebsiteAccount? {
guard let tldPlus1 = tld.eTLDplus1(domain) else {
return nil
}
guard let account = Self.accounts[tldPlus1] else {
return nil
}

guard account.lastUpdated.isLessThan(minutesAgo: 3) else {
Self.accounts.removeValue(forKey: domain)
return nil
}

return account
}

func store(partialAccount: WebsiteAccount, for domain: String) {
guard let tldPlus1 = tld.eTLDplus1(domain) else {
return
}
Self.accounts[tldPlus1] = partialAccount
}

func removePartialAccount(for domain: String) {
guard let tldPlus1 = tld.eTLDplus1(domain) else {
return
}
Self.accounts.removeValue(forKey: tldPlus1)
}
}
49 changes: 38 additions & 11 deletions Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ public class SecureVaultManager {
// This property can be removed once all platforms will search for partial account matches as the default expected behaviour.
private let includePartialAccountMatches: Bool

private let shouldAllowPartialFormSaves: Bool

public let tld: TLD?

// Keeps track of partial account created from autogenerated credentials (Private Email + Pwd)
Expand All @@ -121,6 +123,7 @@ public class SecureVaultManager {
private var autogeneratedCredentials: Bool {
return autogeneratedUserName || autogeneratedPassword
}
private let partialFormSaveManager: PartialFormSaveManager

public lazy var autofillWebsiteAccountMatcher: AutofillWebsiteAccountMatcher? = {
guard let tld = tld else { return nil }
Expand All @@ -131,11 +134,18 @@ public class SecureVaultManager {
public init(vault: (any AutofillSecureVault)? = nil,
passwordManager: PasswordManager? = nil,
includePartialAccountMatches: Bool = false,
shouldAllowPartialFormSaves: Bool = false,
tld: TLD? = nil) {
self.vault = vault
self.passwordManager = passwordManager
self.includePartialAccountMatches = includePartialAccountMatches
self.shouldAllowPartialFormSaves = shouldAllowPartialFormSaves
self.tld = tld
if let tld {
partialFormSaveManager = PartialFormSaveManager(tld: tld)
} else {
partialFormSaveManager = PartialFormSaveManager(tld: TLD())
}
}

}
Expand Down Expand Up @@ -213,6 +223,19 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
}

var autofilldata = data

if shouldAllowPartialFormSaves,
let partialAccountUsername = partialFormSaveManager.partialAccount(forDomain: domain)?.username,
!partialAccountUsername.isEmpty,
let credentials = autofilldata.credentials,
credentials.username.isNilOrEmpty {
autofilldata.credentials = .init(
username: partialAccountUsername,
password: credentials.password,
autogenerated: credentials.autogenerated
)
}

let vault = try? self.vault ?? AutofillSecureVaultFactory.makeVault(reporter: self.delegate)
var autoSavedCredentials: SecureVaultModels.WebsiteCredentials?

Expand Down Expand Up @@ -240,17 +263,13 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
autosaveAccount = nil
}

if autofilldata.trigger == .emailProtection {
switch autofilldata.trigger {
case .emailProtection:
autogeneratedUserName = data.credentials?.autogenerated ?? false
}

if autofilldata.trigger == .passwordGeneration {
case .passwordGeneration:
autogeneratedPassword = data.credentials?.autogenerated ?? false
NotificationCenter.default.post(name: .autofillFillEvent, object: nil)
}

// Account for cases when the user has manually changed an autogenerated password or private email
if autofilldata.trigger == .formSubmission {
case .formSubmission:
if autosaveAccount != nil, let credentials = autoSavedCredentials {

let existingUsername = credentials.account.username
Expand All @@ -267,6 +286,13 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
}

}
case .partialSave:
guard shouldAllowPartialFormSaves else { return }
let username = data.credentials?.username ?? data.identity?.emailAddress
let partialAccount = SecureVaultModels.WebsiteAccount(username: username, domain: domain)
partialFormSaveManager.store(partialAccount: partialAccount, for: domain)
return
case .none, .userInitiated, .autoprompt: break
}

autofilldata.credentials?.autogenerated = autogeneratedCredentials
Expand All @@ -287,6 +313,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
delegate?.secureVaultManager(self, promptUserToStoreAutofillData: dataToPrompt, withTrigger: data.trigger)
autosaveAccount = nil
autosaveAccountCreatedInSession = false
partialFormSaveManager.removePartialAccount(for: domain)
}
return
} else {
Expand All @@ -304,6 +331,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
dataToPrompt.automaticallySavedCredentials = autogeneratedCredentials
autosaveAccount = nil
autosaveAccountCreatedInSession = false
partialFormSaveManager.removePartialAccount(for: domain)
}
delegate?.secureVaultManager(self, promptUserToStoreAutofillData: dataToPrompt, withTrigger: autofilldata.trigger)
}
Expand Down Expand Up @@ -676,12 +704,11 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {

guard let accounts = try? vault.accountsFor(domain: domain),
// Matching account (username) or account with empty username for domain
var account = accounts.first(where: { $0.username == credentials.username || $0.username == "" }) else {
var account = accounts.first(where: { $0.username == credentials.username || $0.username.isNilOrEmpty }) else {

// No existing credentials found. This is a new entry
let account = SecureVaultModels.WebsiteAccount(username: credentials.username ?? "", domain: domain)
return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData)

}

guard let existingAccountId = account.id,
Expand All @@ -702,7 +729,7 @@ extension SecureVaultManager: AutofillSecureVaultDelegate {
}

// Prompt to update the login on submit when previous username was empty (there was a partial password account)
if existingCredentials.account.username == "" {
if existingCredentials.account.username.isNilOrEmpty {
account.username = autofillData.credentials?.username ?? ""
return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Common/Extensions/DateExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,8 @@ public extension Date {
self > Date().addingTimeInterval(Double(-days) * 24 * 60 * 60)
}

func isLessThan(minutesAgo minutes: Int) -> Bool {
self > Date().addingTimeInterval(Double(-minutes) * 60)
}

}
9 changes: 9 additions & 0 deletions Sources/Common/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,12 @@ public extension StringProtocol {
}

}

public extension Optional where Wrapped == String {
var isNilOrEmpty: Bool {
if case .some(let wrapped) = self {
return wrapped.isEmpty
}
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import Foundation
import GRDB
import SecureStorage
import Common

@testable import BrowserServicesKit

Expand Down Expand Up @@ -57,8 +58,11 @@ internal class MockAutofillDatabaseProvider: AutofillDatabaseProvider {
_credentialsDict[accountID] = credentials
return accountID
} else {
var credentialsToStore = credentials
let id = Int64(_credentialsDict.count + 1)
_credentialsDict[id] = credentials
credentialsToStore.account.id = String(id)
_credentialsDict[id] = credentialsToStore
_accounts.append(credentialsToStore.account)
return id
}
}
Expand All @@ -68,11 +72,15 @@ internal class MockAutofillDatabaseProvider: AutofillDatabaseProvider {
}

func websiteCredentialsForDomain(_ domain: String) throws -> [BrowserServicesKit.SecureVaultModels.WebsiteCredentials] {
return _credentialsForDomainDict[domain] ?? []
return _credentialsForDomainDict[domain] ?? _credentialsDict.values.filter {
$0.account.domain == domain
}
}

func websiteCredentialsForTopLevelDomain(_ eTLDplus1: String) throws -> [BrowserServicesKit.SecureVaultModels.WebsiteCredentials] {
return _credentialsForDomainDict[eTLDplus1] ?? []
return _credentialsForDomainDict[eTLDplus1] ?? _credentialsDict.values.filter {
TLD().eTLDplus1($0.account.domain) == eTLDplus1
}
}

func websiteAccountsForDomain(_ domain: String) throws -> [SecureVaultModels.WebsiteAccount] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class SecureVaultManagerTests: XCTestCase {

self.testVault = DefaultAutofillSecureVault(providers: providers)
self.secureVaultManagerDelegate = MockSecureVaultManagerDelegate()
self.manager = SecureVaultManager(vault: self.testVault)
self.manager = SecureVaultManager(vault: self.testVault, shouldAllowPartialFormSaves: true)
self.manager.delegate = secureVaultManagerDelegate
}

Expand Down Expand Up @@ -854,6 +854,94 @@ class SecureVaultManagerTests: XCTestCase {

}

func testWhenFormSubmittedWithNilUsername_afterPartialSaveWithUsername_storesAndPromptsWithFullCredentials() {
let partialCredentials = AutofillUserScript.IncomingCredentials(username: "[email protected]", password: nil, autogenerated: false)
let partialData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: partialCredentials, creditCard: nil, trigger: .partialSave)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: partialData)

var incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "m4nu4lP4sswOrd", autogenerated: false)
var incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: incomingData)

// Check stored
incomingCredentials = AutofillUserScript.IncomingCredentials(username: "[email protected]", password: "m4nu4lP4sswOrd", autogenerated: false)
incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
let entries = try? manager.existingEntries(for: "fill.dev", autofillData: incomingData)
XCTAssertEqual(entries?.credentials?.account.username, "[email protected]")
XCTAssertEqual(entries?.credentials?.password, Data("m4nu4lP4sswOrd".utf8))

// Check prompted
XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData)
XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.username, "[email protected]")
XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.password, Data("m4nu4lP4sswOrd".utf8))
}

func testWhenFormSubmittedWithNilUsername_afterPartialSaveWithUsername_domainsDifferent_onlyStoresTheFormSubmission() {
let partialCredentials = AutofillUserScript.IncomingCredentials(username: "[email protected]", password: nil, autogenerated: false)
let partialData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: partialCredentials, creditCard: nil, trigger: .partialSave)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "dill.fev", data: partialData)

var incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "m4nu4lP4sswOrd", autogenerated: false)
var incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: incomingData)

// Check stored
incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "m4nu4lP4sswOrd", autogenerated: false)
incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
let entries = try? manager.existingEntries(for: "fill.dev", autofillData: incomingData)
XCTAssertNotEqual(entries?.credentials?.account.username, "[email protected]")
XCTAssertEqual(entries?.credentials?.password, Data("m4nu4lP4sswOrd".utf8))

// Check prompted
XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData)
XCTAssertNotEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.username, "[email protected]")
XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.password, Data("m4nu4lP4sswOrd".utf8))
}

func testWhenFormSubmittedWithNilUsername_afterPartialSaveWithUsername_updatesExistingUsernameOnlyStoredData() {
// Check initial stored
let initialCredentials = SecureVaultModels.WebsiteCredentials(account: .init(username: "[email protected]", domain: "fill.dev"), password: nil)
guard let theID = try? testVault.storeWebsiteCredentials(initialCredentials) else {
XCTFail("Couldn't store initial credentials")
return
}

let partialCredentials = AutofillUserScript.IncomingCredentials(username: "[email protected]", password: nil, autogenerated: false)
let partialData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: partialCredentials, creditCard: nil, trigger: .partialSave)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: partialData)

let incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "m4nu4lP4sswOrd", autogenerated: false)
let incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: incomingData)

// Check prompted
XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData)
// Prompting with an account with ID will result in an update
XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.id, String(theID))
}

func testWhenFormSubmittedWithNilUsername_afterPartialSaveWithUsername_updatesExistingPasswordOnlyStoredData() {
// Check initial stored
let initialCredentials = SecureVaultModels.WebsiteCredentials(account: .init(username: nil, domain: "fill.dev"), password: "m4nu4lP4sswOrd".data(using: .utf8))
guard let theID = try? testVault.storeWebsiteCredentials(initialCredentials) else {
XCTFail("Couldn't store initial credentials")
return
}

let partialCredentials = AutofillUserScript.IncomingCredentials(username: "[email protected]", password: nil, autogenerated: false)
let partialData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: partialCredentials, creditCard: nil, trigger: .partialSave)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: partialData)

let incomingCredentials = AutofillUserScript.IncomingCredentials(username: nil, password: "m4nu4lP4sswOrd", autogenerated: false)
let incomingData = AutofillUserScript.DetectedAutofillData(identity: nil, credentials: incomingCredentials, creditCard: nil, trigger: .formSubmission)
manager.autofillUserScript(mockAutofillUserScript, didRequestStoreDataForDomain: "fill.dev", data: incomingData)

// Check prompted
XCTAssertNotNil(secureVaultManagerDelegate.promptedAutofillData)
// Prompting with an account with ID will result in an update
XCTAssertEqual(secureVaultManagerDelegate.promptedAutofillData?.credentials?.account.id, String(theID))
}

// MARK: - Test Utilities

private func identity(id: Int64? = nil, name: (String, String, String), addressStreet: String?) -> SecureVaultModels.Identity {
Expand Down

0 comments on commit 20df9e2

Please sign in to comment.