Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Sources/App/AppMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,6 @@ struct AppMain: App {
registry.register(DeepSeekProvider())
registry.register(OpenAICodexProvider())
registry.register(CursorProvider())
do {
try LegacyBrandMigration.migratePreferences(
providerIds: registry.registeredProviders.map(\.id)
)
} catch {
FileHandle.standardError.write(
Data("[Filbert] Preference migration failed: \(error.localizedDescription)\n".utf8)
)
}
_viewModel = State(initialValue: QuotaViewModel(registry: registry))
}

Expand Down
173 changes: 12 additions & 161 deletions Sources/Core/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,9 @@ public final class Keychain: @unchecked Sendable {
public static let shared = Keychain()

private let service: String
private let previousService: String?
private let storage: any KeychainStorage
/// Account under which the consolidated JSON blob is stored.
private let account = "providers"
/// Prefix of the pre-consolidation per-provider accounts, read only during
/// the one-time migration below.
private let legacyAccountPrefix = "provider-"

/// Decoded provider-ID → provider-owned secret-field map. `nil` until the
/// first keychain read; an empty dictionary is a valid loaded state.
Expand All @@ -45,19 +41,16 @@ public final class Keychain: @unchecked Sendable {
private convenience init() {
self.init(
storage: SecurityKeychainStorage(),
service: "filbert",
previousService: LegacyBrandIdentifiers.keychainService
service: "filbert"
)
}

init(
storage: any KeychainStorage,
service: String,
previousService: String?
service: String
) {
self.storage = storage
self.service = service
self.previousService = previousService
}

public func save(_ key: String, for providerId: String) throws {
Expand Down Expand Up @@ -108,8 +101,7 @@ public final class Keychain: @unchecked Sendable {
private extension Keychain {
// MARK: - Store access (all callers hold `lock`)

/// Returns the cached store, loading it from the keychain — migrating any
/// legacy per-provider items — on first use.
/// Returns the cached store, loading it from the keychain on first use.
private func loadedStore(
authenticationContext: KeychainAuthenticationContext
) throws -> [String: [String: String]] {
Expand Down Expand Up @@ -141,28 +133,19 @@ private extension Keychain {
private func readStore(
authenticationContext: KeychainAuthenticationContext
) throws -> LoadedStore {
let current: ConsolidatedStore?
do {
if let data = try storage.readData(
guard let data = try storage.readData(
service: service,
account: account,
authenticationContext: authenticationContext
) {
current = try decodeStore(data, error: .loadFailed(errSecDecode))
} else {
current = nil
) else {
return LoadedStore(store: [:], data: nil)
}
let store = try decodeStore(data)
return LoadedStore(store: store, data: data)
} catch let error as KeychainStorageError {
throw KeychainError.loadFailed(error.status)
}

if let current, !current.isLegacy {
return LoadedStore(store: current.store, data: current.data)
}
return try migrateStore(
current: current,
authenticationContext: authenticationContext
)
}

private func writeStore(
Expand All @@ -180,99 +163,6 @@ private extension Keychain {
return data
}

private func migrateStore(
current: ConsolidatedStore?,
authenticationContext: KeychainAuthenticationContext
) throws -> LoadedStore {
var migrated: [String: [String: String]] = [:]
var itemsToDelete: [(service: String, account: String)] = []
var requiresWrite = current?.isLegacy ?? false

if let previousService {
let previousHasItems = try mergeMigrationItems(
service: previousService,
into: &migrated,
itemsToDelete: &itemsToDelete,
authenticationContext: authenticationContext
)
requiresWrite = requiresWrite || previousHasItems

if let consolidated = try legacyConsolidatedStore(
service: previousService,
authenticationContext: authenticationContext
) {
migrated.merge(consolidated.store) { _, latest in latest }
itemsToDelete.append((previousService, account))
requiresWrite = true
}
}

let currentHasItems = try mergeMigrationItems(
service: service,
into: &migrated,
itemsToDelete: &itemsToDelete,
authenticationContext: authenticationContext
)
requiresWrite = requiresWrite || currentHasItems

if let current {
migrated.merge(current.store) { _, latest in latest }
}

guard requiresWrite else {
return LoadedStore(store: current?.store ?? [:], data: current?.data)
}

let data = try JSONEncoder().encode(migrated)
try replaceAndVerify(
data,
previousData: current?.data,
authenticationContext: authenticationContext,
errorFactory: { .migrationFailed($0) }
)
for item in itemsToDelete {
storage.delete(
service: item.service,
account: item.account,
authenticationContext: authenticationContext
)
}
return LoadedStore(store: migrated, data: data)
}

private func mergeMigrationItems(
service: String,
into migrated: inout [String: [String: String]],
itemsToDelete: inout [(service: String, account: String)],
authenticationContext: KeychainAuthenticationContext
) throws -> Bool {
let items = try migrationItems(
service: service,
authenticationContext: authenticationContext
)
migrated.merge(items.store) { _, latest in latest }
itemsToDelete.append(contentsOf: items.accounts.map { (service, $0) })
return !items.accounts.isEmpty
}

private func legacyConsolidatedStore(
service: String,
authenticationContext: KeychainAuthenticationContext
) throws -> ConsolidatedStore? {
do {
guard let data = try storage.readData(
service: service,
account: account,
authenticationContext: authenticationContext
) else {
return nil
}
return try decodeStore(data, error: .migrationFailed(errSecDecode))
} catch let error as KeychainStorageError {
throw KeychainError.migrationFailed(error.status)
}
}

private func replaceAndVerify(
_ data: Data,
previousData: Data?,
Expand Down Expand Up @@ -330,53 +220,15 @@ private extension Keychain {
}
}

private func migrationItems(
service: String,
authenticationContext: KeychainAuthenticationContext
) throws -> (store: [String: [String: String]], accounts: [String]) {
let items: [StoredKeychainItem]
private func decodeStore(_ data: Data) throws -> [String: [String: String]] {
do {
items = try storage.readLegacyItems(
service: service,
accountPrefix: legacyAccountPrefix,
authenticationContext: authenticationContext
)
} catch let error as KeychainStorageError {
throw KeychainError.migrationFailed(error.status)
}

var migrated: [String: [String: String]] = [:]
var accounts: [String] = []
for item in items where item.account.hasPrefix(legacyAccountPrefix) {
guard let key = String(data: item.data, encoding: .utf8) else { continue }
let providerId = String(item.account.dropFirst(legacyAccountPrefix.count))
migrated[providerId] = ["value": key]
accounts.append(item.account)
}
return (migrated, accounts)
}

private func decodeStore(
_ data: Data,
error: KeychainError
) throws -> ConsolidatedStore {
if let store = try? JSONDecoder().decode([String: [String: String]].self, from: data) {
return ConsolidatedStore(store: store, data: data, isLegacy: false)
}
if let legacyStore = try? JSONDecoder().decode([String: String].self, from: data) {
let store = legacyStore.mapValues { ["value": $0] }
return ConsolidatedStore(store: store, data: data, isLegacy: true)
return try JSONDecoder().decode([String: [String: String]].self, from: data)
} catch {
throw KeychainError.loadFailed(errSecDecode)
}
throw error
}
}

private struct ConsolidatedStore {
let store: [String: [String: String]]
let data: Data
let isLegacy: Bool
}

private struct LoadedStore {
let store: [String: [String: String]]
let data: Data?
Expand All @@ -386,7 +238,6 @@ public enum KeychainError: Error, Equatable {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
case migrationFailed(OSStatus)
}

private extension KeychainStorageError {
Expand Down
52 changes: 0 additions & 52 deletions Sources/Core/KeychainStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,12 @@ final class KeychainAuthenticationContext: @unchecked Sendable {
let localAuthenticationContext = LAContext()
}

struct StoredKeychainItem: Sendable {
let account: String
let data: Data
}

protocol KeychainStorage: Sendable {
func readData(
service: String,
account: String,
authenticationContext: KeychainAuthenticationContext
) throws -> Data?
func readLegacyItems(
service: String,
accountPrefix: String,
authenticationContext: KeychainAuthenticationContext
) throws -> [StoredKeychainItem]
func replaceData(
_ data: Data,
service: String,
Expand Down Expand Up @@ -69,48 +59,6 @@ struct SecurityKeychainStorage: KeychainStorage {
}
}

func readLegacyItems(
service: String,
accountPrefix: String,
authenticationContext: KeychainAuthenticationContext
) throws -> [StoredKeychainItem] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecUseAuthenticationContext as String: authenticationContext.localAuthenticationContext,
]

var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
return []
}
guard status == errSecSuccess else {
throw KeychainStorageError.status(status)
}
guard let items = result as? [[String: Any]] else {
throw KeychainStorageError.status(errSecDecode)
}

var legacyItems: [StoredKeychainItem] = []
for item in items {
guard let account = item[kSecAttrAccount as String] as? String,
account.hasPrefix(accountPrefix),
let data = try readData(
service: service,
account: account,
authenticationContext: authenticationContext
)
else {
continue
}
legacyItems.append(StoredKeychainItem(account: account, data: data))
}
return legacyItems
}

func replaceData(
_ data: Data,
service: String,
Expand Down
63 changes: 0 additions & 63 deletions Sources/Core/LegacyBrandMigration.swift

This file was deleted.

Loading
Loading