Skip to content

Commit

Permalink
iOS System level credential provider (#1127)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/72649045549333/1207512172220035/f
iOS PR: duckduckgo/iOS#3699
macOS PR: duckduckgo/macos-browser#3628
What kind of version bump will this require?: Minor

Description:
Adds support for system level credential provider changes on iOS
  • Loading branch information
amddg44 authored Dec 9, 2024
1 parent 20df9e2 commit 6c335fb
Show file tree
Hide file tree
Showing 17 changed files with 1,350 additions and 31 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
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Common
import Foundation
import GRDB
import SecureStorage
import os.log

public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider {

Expand Down Expand Up @@ -82,14 +83,35 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider {

public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabaseProvider, AutofillDatabaseProvider {

struct Constants {
static let dbDirectoryName = "Vault"
static let dbFileName = "Vault.db"
}

public static func defaultDatabaseURL() -> URL {
return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: "Vault", fileName: "Vault.db")
return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: Constants.dbDirectoryName, fileName: Constants.dbFileName, appGroupIdentifier: nil)
}

public static func defaultSharedDatabaseURL() -> URL {
return DefaultAutofillDatabaseProvider.databaseFilePath(directoryName: Constants.dbDirectoryName, fileName: Constants.dbFileName, appGroupIdentifier: Bundle.main.appGroupPrefix + ".vault")
}

public init(file: URL = DefaultAutofillDatabaseProvider.defaultDatabaseURL(),
key: Data,
fileStorageManager: FileStorageManaging = AppGroupFileStorageManager(),
customMigrations: ((inout DatabaseMigrator) -> Void)? = nil) throws {
try super.init(file: file, key: key, writerType: .queue) { migrator in
let databaseURL: URL

#if os(iOS)
databaseURL = Self.migrateDatabaseToSharedGroupIfNeeded(using: fileStorageManager,
from: Self.defaultDatabaseURL(),
to: Self.defaultSharedDatabaseURL())
#else
// macOS stays in its sandbox location
databaseURL = file
#endif

try super.init(file: databaseURL, key: key, writerType: .queue) { migrator in
if let customMigrations {
customMigrations(&migrator)
} else {
Expand All @@ -110,6 +132,17 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro
}
}

private static func migrateDatabaseToSharedGroupIfNeeded(using fileStorageManager: FileStorageManaging = AppGroupFileStorageManager(),
from databaseURL: URL,
to sharedDatabaseURL: URL) -> URL {
do {
return try fileStorageManager.migrateDatabaseToSharedStorageIfNeeded(from: databaseURL, to: sharedDatabaseURL)
} catch {
Logger.secureStorage.error("Failed to migrate database to shared storage: \(error.localizedDescription)")
return databaseURL
}
}

public func inTransaction(_ block: @escaping (GRDB.Database) throws -> Void) throws {
try db.write { database in
try block(database)
Expand Down Expand Up @@ -1412,3 +1445,17 @@ extension SecureVaultModels.Identity: PersistableRecord, FetchableRecord {
public static var databaseTableName: String = "identities"

}

private extension Bundle {
var appGroupPrefix: String {
let groupIdPrefixKey = "DuckDuckGoGroupIdentifierPrefix"
guard let groupIdPrefix = Bundle.main.object(forInfoDictionaryKey: groupIdPrefixKey) as? String else {
#if DEBUG && os(iOS)
return "group.com.duckduckgo.test"
#else
fatalError("Info.plist must contain a \"\(groupIdPrefixKey)\" entry with a string value")
#endif
}
return groupIdPrefix
}
}
Loading

0 comments on commit 6c335fb

Please sign in to comment.