Files
steipete--codexbar/Sources/CodexBar/KeychainMigration.swift
T
2026-07-13 12:22:33 +08:00

155 lines
5.8 KiB
Swift

import CodexBarCore
import Foundation
import Security
/// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
/// to prevent permission prompts on every rebuild during development.
enum KeychainMigration {
private static let log = CodexBarLog.logger(LogCategories.keychainMigration)
private static let migrationKey = "KeychainMigrationV1Completed"
struct MigrationItem: Hashable {
let service: String
let account: String?
var label: String {
let accountLabel = self.account ?? "<any>"
return "\(self.service):\(accountLabel)"
}
}
static let itemsToMigrate: [MigrationItem] = [
MigrationItem(service: "com.steipete.CodexBar", account: "codex-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "claude-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "cursor-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "factory-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "minimax-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "minimax-api-token"),
MigrationItem(service: "com.steipete.CodexBar", account: "augment-cookie"),
MigrationItem(service: "com.steipete.CodexBar", account: "copilot-api-token"),
MigrationItem(service: "com.steipete.CodexBar", account: "zai-api-token"),
MigrationItem(service: "com.steipete.CodexBar", account: "synthetic-api-key"),
]
/// Run migration once per installation
static func migrateIfNeeded() {
guard !KeychainAccessGate.isDisabled else {
self.log.info("Keychain access disabled; skipping migration")
return
}
if !UserDefaults.standard.bool(forKey: self.migrationKey) {
self.log.info("Starting keychain migration to reduce permission prompts")
var migratedCount = 0
var errorCount = 0
for item in self.itemsToMigrate {
do {
if try self.migrateItem(item) {
migratedCount += 1
}
} catch {
errorCount += 1
self.log.error("Failed to migrate \(item.label): \(String(describing: error))")
}
}
self.log.info("Keychain migration complete: \(migratedCount) migrated, \(errorCount) errors")
UserDefaults.standard.set(true, forKey: self.migrationKey)
if migratedCount > 0 {
self.log.info("✅ Future rebuilds will not prompt for keychain access")
}
} else {
self.log.debug("Keychain migration already completed, skipping")
}
}
/// Migrate a single keychain item to the new accessibility level
/// Returns true if item was migrated, false if item didn't exist
private static func migrateItem(_ item: MigrationItem) throws -> Bool {
// First, try to read the existing item
var result: CFTypeRef?
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: item.service,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecReturnAttributes as String: true,
]
if let account = item.account {
query[kSecAttrAccount as String] = account
}
let status = KeychainSecurity.copyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
// Item doesn't exist, nothing to migrate
return false
}
guard status == errSecSuccess else {
throw KeychainMigrationError.readFailed(status)
}
guard let rawItem = result as? [String: Any],
let data = rawItem[kSecValueData as String] as? Data,
let accessible = rawItem[kSecAttrAccessible as String] as? String
else {
throw KeychainMigrationError.invalidItemFormat
}
// Check if already using the correct accessibility
if accessible == (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) {
self.log.debug("\(item.label) already using correct accessibility")
return false
}
// Delete the old item
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: item.service,
]
if let account = item.account {
deleteQuery[kSecAttrAccount as String] = account
}
let deleteStatus = KeychainSecurity.delete(deleteQuery as CFDictionary)
guard deleteStatus == errSecSuccess else {
throw KeychainMigrationError.deleteFailed(deleteStatus)
}
// Add it back with the new accessibility
var addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: item.service,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
if let account = item.account {
addQuery[kSecAttrAccount as String] = account
}
let addStatus = KeychainSecurity.add(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainMigrationError.addFailed(addStatus)
}
self.log.info("Migrated \(item.label) to new accessibility level")
return true
}
/// Reset migration flag (for testing)
static func resetMigrationFlag() {
UserDefaults.standard.removeObject(forKey: self.migrationKey)
}
}
enum KeychainMigrationError: Error {
case readFailed(OSStatus)
case deleteFailed(OSStatus)
case addFailed(OSStatus)
case invalidItemFormat
}