Skip to content

Commit

Permalink
QuickType support for iOS (#1123)
Browse files Browse the repository at this point in the history
Task/Issue URL: 
iOS PR: duckduckgo/iOS#3696
macOS PR: duckduckgo/macos-browser#3648
What kind of version bump will this require?: Minor

**Description**:
Adds support for QuickType to the Credential provider extension along
with deduplication logic
  • Loading branch information
amddg44 authored Dec 9, 2024
1 parent 8bfa2a6 commit a374b64
Show file tree
Hide file tree
Showing 9 changed files with 863 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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 {}
Original file line number Diff line number Diff line change
@@ -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
}

}
}
32 changes: 32 additions & 0 deletions Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()

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 }
Expand Down
16 changes: 15 additions & 1 deletion Sources/SyncDataProviders/Credentials/CredentialsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<MetricsEvent>? = 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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit a374b64

Please sign in to comment.