Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autofill credential migration to shared storage #1026

Merged
Merged
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
}
}
132 changes: 115 additions & 17 deletions Sources/BrowserServicesKit/SecureVault/AutofillKeyStoreProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,55 @@ import Foundation
import SecureStorage
import os.log

protocol KeyStorePlatformProviding {
var keychainServiceName: String { get }
func keychainIdentifier(for rawValue: String) -> String
var keychainSecurityGroup: String { get }
}

struct iOSKeyStorePlatformProvider: KeyStorePlatformProviding {
private let appGroupName: String

// Using appGroupName in the initializer, allowing injection for tests
init(appGroupName: String = Bundle.main.appGroupName) {
self.appGroupName = appGroupName
}

var keychainServiceName: String {
return AutofillKeyStoreProvider.Constants.v4ServiceName
}

func keychainIdentifier(for rawValue: String) -> String {
return appGroupName + rawValue
}

var keychainSecurityGroup: String {
return appGroupName
}
}

struct macOSKeyStorePlatformProvider: KeyStorePlatformProviding {
var keychainServiceName: String {
return AutofillKeyStoreProvider.Constants.v3ServiceName
}

func keychainIdentifier(for rawValue: String) -> String {
return (Bundle.main.bundleIdentifier ?? "com.duckduckgo") + rawValue
}

var keychainSecurityGroup: String {
return ""
}

}

final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {

struct Constants {
static let v1ServiceName = "DuckDuckGo Secure Vault"
static let v2ServiceName = "DuckDuckGo Secure Vault v2"
static let v3ServiceName = "DuckDuckGo Secure Vault v3"
static let v4ServiceName = "DuckDuckGo Secure Vault v4"
}

// DO NOT CHANGE except if you want to deliberately invalidate all users's vaults.
Expand All @@ -38,7 +81,12 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
case l2Key = "A5711F4D-7AA5-4F0C-9E4F-BE553F1EA299"

// `keychainIdentifier` should be used as Keychain Account names, as app variants (e.g App Store, DMG) should have separate entries
var keychainIdentifier: String {
func keychainIdentifier(using platformProvider: KeyStorePlatformProviding) -> String {
return platformProvider.keychainIdentifier(for: self.rawValue)
}

// `legacyKeychainIdentifier` is the Keychain Account name pre migration to shared app groups (currently only on iOS)
var legacyKeychainIdentifier: String {
(Bundle.main.bundleIdentifier ?? "com.duckduckgo") + rawValue
}

Expand All @@ -53,13 +101,13 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
}
}

init?(_ keyValue: String) {
init?(_ keyValue: String, using platformProvider: KeyStorePlatformProviding) {
switch keyValue {
case EntryName.generatedPassword.keychainIdentifier:
case platformProvider.keychainIdentifier(for: EntryName.generatedPassword.rawValue), EntryName.generatedPassword.legacyKeychainIdentifier:
self = .generatedPassword
case EntryName.l1Key.keychainIdentifier:
case platformProvider.keychainIdentifier(for: EntryName.l1Key.rawValue), EntryName.l1Key.legacyKeychainIdentifier:
self = .l1Key
case EntryName.l2Key.keychainIdentifier:
case platformProvider.keychainIdentifier(for: EntryName.l2Key.rawValue), EntryName.l2Key.legacyKeychainIdentifier:
self = .l2Key
default:
return nil
Expand All @@ -69,27 +117,40 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {

let keychainService: KeychainService
private var reporter: SecureVaultReporting?
private let platformProvider: KeyStorePlatformProviding

init(keychainService: KeychainService = DefaultKeychainService(),
reporter: SecureVaultReporting? = nil) {
reporter: SecureVaultReporting? = nil,
platformProvider: KeyStorePlatformProviding? = nil) {
self.keychainService = keychainService
self.reporter = reporter

// Use default platform provider based on the platform.
if let platformProvider = platformProvider {
self.platformProvider = platformProvider
} else {
#if os(iOS)
self.platformProvider = iOSKeyStorePlatformProvider()
#else
self.platformProvider = macOSKeyStorePlatformProvider()
#endif
}
}

var keychainServiceName: String {
return Constants.v3ServiceName
return platformProvider.keychainServiceName
}

var generatedPasswordEntryName: String {
return EntryName.generatedPassword.keychainIdentifier
return EntryName.generatedPassword.keychainIdentifier(using: platformProvider)
}

var l1KeyEntryName: String {
return EntryName.l1Key.keychainIdentifier
return EntryName.l1Key.keychainIdentifier(using: platformProvider)
}

var l2KeyEntryName: String {
return EntryName.l2Key.keychainIdentifier
return EntryName.l2Key.keychainIdentifier(using: platformProvider)
}

func readData(named name: String, serviceName: String) throws -> Data? {
Expand All @@ -103,15 +164,19 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
/// - Returns: Optional data
private func readOrMigrate(named name: String, serviceName: String) throws -> Data? {
if let data = try read(named: name, serviceName: serviceName) {
Logger.autofill.debug("Autofill Keystore data retrieved")
Logger.autofill.debug("Autofill Keystore \(serviceName) data retrieved")
return data
} else {
guard let entryName = EntryName(name) else { return nil }
guard let entryName = EntryName(name, using: platformProvider) else { return nil }

reporter?.secureVaultKeyStoreEvent(entryName.keyStoreMigrationEvent)

// If V4 migration, look for items in V3 vault (i.e pre-shared Keychain storage)
if isPostV3(serviceName), let data = try migrateEntry(entryName: entryName, serviceName: Constants.v3ServiceName) {
Logger.autofill.debug("Migrated V3 Autofill Keystore data")
return data
// Look for items in V2 vault (i.e pre-bundle-specifc Keychain storage)
if let data = try migrateEntry(entryName: entryName, serviceName: Constants.v2ServiceName) {
} else if let data = try migrateEntry(entryName: entryName, serviceName: Constants.v2ServiceName) {
Logger.autofill.debug("Migrated V2 Autofill Keystore data")
return data
// Look for items in V1 vault
Expand All @@ -120,6 +185,7 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
return data
}

Logger.autofill.debug("Keychain migration failed for \(name)")
return nil
}
}
Expand All @@ -139,7 +205,7 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
let status = keychainService.itemMatching(query, &item)
switch status {
case errSecSuccess:
if isPostV1(serviceName) {
if isPostV1(serviceName) || isPostV3(serviceName) {
guard let itemData = item as? Data,
let itemString = String(data: itemData, encoding: .utf8),
let decodedData = Data(base64Encoded: itemString) else {
Expand All @@ -162,26 +228,33 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {

/// Migrates an entry to new bundle-specific Keychain storage
/// - Parameters:
/// - entryName: Entry to migrate. It's `rawValue` is used when reading from old storage, and it's `keyValue` is used when writing to storage
/// - entryName: Entry to migrate. It's `rawValue` is used when reading from old storage pre-V2, while its `legacyKeychainIdentifier` is used post V2, and it's `keyValue` is used when writing to storage
/// - serviceName: Service name to use when querying Keychain for the entry
/// - Returns: Optional data
private func migrateEntry(entryName: EntryName, serviceName: String) throws -> Data? {
guard let data = try read(named: entryName.rawValue, serviceName: serviceName) else {
let name = serviceName == Constants.v3ServiceName ? entryName.legacyKeychainIdentifier : entryName.rawValue
guard let data = try read(named: name, serviceName: serviceName) else {
return nil
}
try writeData(data, named: entryName.keychainIdentifier, serviceName: keychainServiceName)
try writeData(data, named: entryName.keychainIdentifier(using: platformProvider), serviceName: keychainServiceName)
return data
}

private func isPostV1(_ serviceName: String) -> Bool {
[Constants.v2ServiceName, Constants.v3ServiceName].contains(serviceName)
}

private func isPostV3(_ serviceName: String) -> Bool {
[Constants.v4ServiceName].contains(serviceName)
}

// MARK: - Autofill Attributes

func attributesForEntry(named name: String, serviceName: String) -> [String: Any] {
if isPostV1(serviceName) {
return defaultAttributesForEntry(named: name)
} else if isPostV3(serviceName) {
return defaultAttributesForSharedEntry(named: name)
} else {
return legacyAttributesForEntry(named: name)
}
Expand All @@ -205,4 +278,29 @@ final class AutofillKeyStoreProvider: SecureStorageKeyStoreProvider {
] as [String: Any]
}

private func defaultAttributesForSharedEntry(named name: String) -> [String: Any] {
return [
kSecClass: kSecClassGenericPassword,
kSecUseDataProtectionKeychain: false,
kSecAttrSynchronizable: false,
kSecAttrAccount: name,
kSecAttrAccessGroup: platformProvider.keychainSecurityGroup
] as [String: Any]
}
}

fileprivate extension Bundle {

static let vaultAppGroupName = "VAULT_APP_GROUP"

var appGroupName: String {
guard let appGroup = object(forInfoDictionaryKey: Bundle.vaultAppGroupName) as? String else {
#if DEBUG && os(iOS)
return "com.duckduckgo.vault.test"
#else
fatalError("Info.plist is missing \(Bundle.vaultAppGroupName)")
#endif
}
return appGroup
}
}
Loading
Loading