2026-02-07 22:56:52 +00:00
|
|
|
import Foundation
|
|
|
|
|
import Security
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Token Keys
|
|
|
|
|
// Logical API-token keys mapped to Keychain account names.
|
2026-02-07 22:56:52 +00:00
|
|
|
enum APITokenKey: String, CaseIterable {
|
|
|
|
|
case grok
|
|
|
|
|
case openAI
|
|
|
|
|
case gemini
|
|
|
|
|
case anthropic
|
|
|
|
|
|
|
|
|
|
var account: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case .grok: return "GrokAPIToken"
|
|
|
|
|
case .openAI: return "OpenAIAPIToken"
|
|
|
|
|
case .gemini: return "GeminiAPIToken"
|
|
|
|
|
case .anthropic: return "AnthropicAPIToken"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Secure Token Store
|
|
|
|
|
// Keychain-backed storage for provider API tokens.
|
2026-02-07 22:56:52 +00:00
|
|
|
enum SecureTokenStore {
|
|
|
|
|
private static let service = "h3p.Neon-Vision-Editor.tokens"
|
2026-02-14 13:24:01 +00:00
|
|
|
private static let debugTokenPrefix = "DebugTokenStore."
|
2026-02-07 22:56:52 +00:00
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Returns UTF-8 token value or empty string when token is missing.
|
2026-02-07 22:56:52 +00:00
|
|
|
static func token(for key: APITokenKey) -> String {
|
2026-02-14 13:24:01 +00:00
|
|
|
#if DEBUG
|
|
|
|
|
let debugValue = UserDefaults.standard.string(forKey: debugTokenPrefix + key.account) ?? ""
|
|
|
|
|
return debugValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
#else
|
2026-02-07 22:56:52 +00:00
|
|
|
guard let data = readData(for: key),
|
|
|
|
|
let value = String(data: data, encoding: .utf8) else {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return value
|
2026-02-14 13:24:01 +00:00
|
|
|
#endif
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
@discardableResult
|
|
|
|
|
// Writes token to Keychain or deletes entry when value is empty.
|
|
|
|
|
static func setToken(_ value: String, for key: APITokenKey) -> Bool {
|
2026-02-07 22:56:52 +00:00
|
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
2026-02-14 13:24:01 +00:00
|
|
|
#if DEBUG
|
2026-02-07 22:56:52 +00:00
|
|
|
if trimmed.isEmpty {
|
2026-02-14 13:24:01 +00:00
|
|
|
UserDefaults.standard.removeObject(forKey: debugTokenPrefix + key.account)
|
|
|
|
|
return true
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
UserDefaults.standard.set(trimmed, forKey: debugTokenPrefix + key.account)
|
|
|
|
|
return true
|
|
|
|
|
#else
|
|
|
|
|
if trimmed.isEmpty {
|
|
|
|
|
return deleteToken(for: key)
|
|
|
|
|
}
|
|
|
|
|
guard let data = trimmed.data(using: .utf8) else { return false }
|
2026-02-07 22:56:52 +00:00
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
let baseQuery = baseQuery(for: key)
|
|
|
|
|
|
|
|
|
|
let updateAttributes: [CFString: Any] = [kSecValueData: data]
|
|
|
|
|
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttributes as CFDictionary)
|
|
|
|
|
if updateStatus == errSecSuccess {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-02-07 22:56:52 +00:00
|
|
|
|
|
|
|
|
if updateStatus == errSecItemNotFound {
|
|
|
|
|
var addQuery = baseQuery
|
2026-02-14 13:24:01 +00:00
|
|
|
addQuery[kSecValueData] = data
|
|
|
|
|
// Keep secrets device-bound and unavailable while the device is locked.
|
|
|
|
|
addQuery[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
|
|
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
|
|
|
if addStatus == errSecSuccess {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
logKeychainError(status: addStatus, context: "add token \(key.account)")
|
|
|
|
|
return false
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
2026-02-14 13:24:01 +00:00
|
|
|
|
|
|
|
|
logKeychainError(status: updateStatus, context: "update token \(key.account)")
|
|
|
|
|
return false
|
|
|
|
|
#endif
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Migrates legacy UserDefaults tokens into Keychain and cleans stale defaults.
|
2026-02-07 22:56:52 +00:00
|
|
|
static func migrateLegacyUserDefaultsTokens() {
|
|
|
|
|
for key in APITokenKey.allCases {
|
|
|
|
|
let defaultsKey = key.account
|
|
|
|
|
let defaultsValue = UserDefaults.standard.string(forKey: defaultsKey) ?? ""
|
2026-02-14 13:24:01 +00:00
|
|
|
let trimmedDefaultsValue = defaultsValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
let hasStoredValue = !token(for: key).isEmpty
|
|
|
|
|
|
|
|
|
|
if !hasStoredValue && !trimmedDefaultsValue.isEmpty {
|
|
|
|
|
let didStore = setToken(trimmedDefaultsValue, for: key)
|
|
|
|
|
if didStore {
|
|
|
|
|
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if hasStoredValue || trimmedDefaultsValue.isEmpty {
|
|
|
|
|
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func readData(for key: APITokenKey) -> Data? {
|
2026-02-14 13:24:01 +00:00
|
|
|
var query = baseQuery(for: key)
|
|
|
|
|
query[kSecReturnData] = kCFBooleanTrue
|
|
|
|
|
query[kSecMatchLimit] = kSecMatchLimitOne
|
2026-02-07 22:56:52 +00:00
|
|
|
|
|
|
|
|
var item: CFTypeRef?
|
|
|
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
2026-02-14 13:24:01 +00:00
|
|
|
if status == errSecItemNotFound || isMissingDataStoreStatus(status) {
|
|
|
|
|
// Some environments report missing keychain backends with legacy CSSM errors.
|
|
|
|
|
// Treat them as "token not present" to keep app startup resilient.
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
guard status == errSecSuccess else {
|
|
|
|
|
logKeychainError(status: status, context: "read token \(key.account)")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
guard let data = item as? Data else {
|
|
|
|
|
logKeychainError(status: errSecInternalError, context: "read token \(key.account) returned non-data payload")
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
// Deletes a token entry from Keychain.
|
|
|
|
|
private static func deleteToken(for key: APITokenKey) -> Bool {
|
|
|
|
|
let query = baseQuery(for: key)
|
|
|
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
|
|
|
if status == errSecSuccess || status == errSecItemNotFound {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
logKeychainError(status: status, context: "delete token \(key.account)")
|
|
|
|
|
return false
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Builds a strongly-typed keychain query to avoid CF bridging issues at runtime.
|
|
|
|
|
private static func baseQuery(for key: APITokenKey) -> [CFString: Any] {
|
|
|
|
|
[
|
|
|
|
|
kSecClass: kSecClassGenericPassword,
|
|
|
|
|
kSecAttrService: service,
|
|
|
|
|
kSecAttrAccount: key.account
|
2026-02-07 22:56:52 +00:00
|
|
|
]
|
2026-02-14 13:24:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Maps legacy CSSM keychain "store missing" errors to a benign "not found" condition.
|
|
|
|
|
private static func isMissingDataStoreStatus(_ status: OSStatus) -> Bool {
|
|
|
|
|
status == errSecNoSuchKeychain || status == errSecNotAvailable || status == errSecInteractionNotAllowed || status == -2147413737
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Emits consistent Keychain error diagnostics for support/debugging.
|
|
|
|
|
private static func logKeychainError(status: OSStatus, context: String) {
|
|
|
|
|
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Unknown OSStatus"
|
|
|
|
|
NSLog("SecureTokenStore error (\(context)): \(status) - \(message)")
|
2026-02-07 22:56:52 +00:00
|
|
|
}
|
|
|
|
|
}
|