diff --git a/VoiceInk/Models/LicenseViewModel.swift b/VoiceInk/Models/LicenseViewModel.swift index fdef606..e867d52 100644 --- a/VoiceInk/Models/LicenseViewModel.swift +++ b/VoiceInk/Models/LicenseViewModel.swift @@ -8,44 +8,45 @@ class LicenseViewModel: ObservableObject { case trialExpired case licensed } - + @Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial @Published var licenseKey: String = "" @Published var isValidating = false @Published var validationMessage: String? @Published private(set) var activationsLimit: Int = 0 - + private let trialPeriodDays = 7 private let polarService = PolarService() private let userDefaults = UserDefaults.standard - + private let licenseManager = LicenseManager.shared + init() { loadLicenseState() } - + func startTrial() { // Only set trial start date if it hasn't been set before - if userDefaults.trialStartDate == nil { - userDefaults.trialStartDate = Date() + if licenseManager.trialStartDate == nil { + licenseManager.trialStartDate = Date() licenseState = .trial(daysRemaining: trialPeriodDays) NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) } } - + private func loadLicenseState() { // Check for existing license key - if let licenseKey = userDefaults.licenseKey { - self.licenseKey = licenseKey - + if let storedLicenseKey = licenseManager.licenseKey { + self.licenseKey = storedLicenseKey + // If we have a license key, trust that it's licensed // Skip server validation on startup - if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { + if licenseManager.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { licenseState = .licensed activationsLimit = userDefaults.activationsLimit return } } - + // Check if this is first launch let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore") if !hasLaunchedBefore { @@ -54,11 +55,11 @@ class LicenseViewModel: ObservableObject { startTrial() return } - + // Only check trial if not licensed and not first launch - if let trialStartDate = userDefaults.trialStartDate { + if let trialStartDate = licenseManager.trialStartDate { let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0 - + if daysSinceTrialStart >= trialPeriodDays { licenseState = .trialExpired } else { @@ -104,13 +105,13 @@ class LicenseViewModel: ObservableObject { } // Store the license key - userDefaults.licenseKey = licenseKey - + licenseManager.licenseKey = licenseKey + // Handle based on whether activation is required if licenseCheck.requiresActivation { // If we already have an activation ID, validate with it - if let activationId = userDefaults.activationId { - let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId) + if let existingActivationId = licenseManager.activationId { + let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId) if isValid { // Existing activation is valid licenseState = .licensed @@ -120,23 +121,23 @@ class LicenseViewModel: ObservableObject { return } } - + // Need to create a new activation - let (activationId, limit) = try await polarService.activateLicenseKey(licenseKey) - + let (newActivationId, limit) = try await polarService.activateLicenseKey(licenseKey) + // Store activation details - userDefaults.activationId = activationId + licenseManager.activationId = newActivationId userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = limit userDefaults.activationsLimit = limit - + } else { // This license doesn't require activation (unlimited devices) - userDefaults.activationId = nil + licenseManager.activationId = nil userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = licenseCheck.activationsLimit ?? 0 userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0 - + // Update the license state for unlimited license licenseState = .licensed validationMessage = "License validated successfully!" @@ -154,12 +155,12 @@ class LicenseViewModel: ObservableObject { validationMessage = "Activation limit reached: \(details)" } catch LicenseError.activationNotRequired { // This is actually a success case for unlimited licenses - userDefaults.licenseKey = licenseKey - userDefaults.activationId = nil + licenseManager.licenseKey = licenseKey + licenseManager.activationId = nil userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") self.activationsLimit = 0 userDefaults.activationsLimit = 0 - + licenseState = .licensed validationMessage = "License activated successfully!" NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) @@ -171,15 +172,14 @@ class LicenseViewModel: ObservableObject { } func removeLicense() { - // Remove both license key and trial data - userDefaults.licenseKey = nil - userDefaults.activationId = nil + // Remove all license data from Keychain + licenseManager.removeAll() + + // Reset UserDefaults flags userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") - userDefaults.trialStartDate = nil userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart - userDefaults.activationsLimit = 0 - + licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state licenseKey = "" validationMessage = nil @@ -190,13 +190,8 @@ class LicenseViewModel: ObservableObject { } -// Add UserDefaults extensions for storing activation ID +// UserDefaults extension for non-sensitive license settings extension UserDefaults { - var activationId: String? { - get { string(forKey: "VoiceInkActivationId") } - set { set(newValue, forKey: "VoiceInkActivationId") } - } - var activationsLimit: Int { get { integer(forKey: "VoiceInkActivationsLimit") } set { set(newValue, forKey: "VoiceInkActivationsLimit") } diff --git a/VoiceInk/Models/TranscriptionModel.swift b/VoiceInk/Models/TranscriptionModel.swift index 2617682..3276682 100644 --- a/VoiceInk/Models/TranscriptionModel.swift +++ b/VoiceInk/Models/TranscriptionModel.swift @@ -91,7 +91,7 @@ struct CloudModel: TranscriptionModel { } } -// A new struct for custom cloud models +/// Custom cloud model with API key stored in Keychain. struct CustomCloudModel: TranscriptionModel, Codable { let id: UUID let name: String @@ -99,22 +99,59 @@ struct CustomCloudModel: TranscriptionModel, Codable { let description: String let provider: ModelProvider = .custom let apiEndpoint: String - let apiKey: String let modelName: String let isMultilingualModel: Bool let supportedLanguages: [String: String] - init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, apiKey: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) { + /// API key retrieved from Keychain by model ID. + var apiKey: String { + APIKeyManager.shared.getCustomModelAPIKey(forModelId: id) ?? "" + } + + init(id: UUID = UUID(), name: String, displayName: String, description: String, apiEndpoint: String, modelName: String, isMultilingual: Bool = true, supportedLanguages: [String: String]? = nil) { self.id = id self.name = name self.displayName = displayName self.description = description self.apiEndpoint = apiEndpoint - self.apiKey = apiKey self.modelName = modelName self.isMultilingualModel = isMultilingual self.supportedLanguages = supportedLanguages ?? PredefinedModels.getLanguageDictionary(isMultilingual: isMultilingual) } + + /// Custom Codable to migrate legacy apiKey from JSON to Keychain. + private enum CodingKeys: String, CodingKey { + case id, name, displayName, description, apiEndpoint, modelName, isMultilingualModel, supportedLanguages + case apiKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + displayName = try container.decode(String.self, forKey: .displayName) + description = try container.decode(String.self, forKey: .description) + apiEndpoint = try container.decode(String.self, forKey: .apiEndpoint) + modelName = try container.decode(String.self, forKey: .modelName) + isMultilingualModel = try container.decode(Bool.self, forKey: .isMultilingualModel) + supportedLanguages = try container.decode([String: String].self, forKey: .supportedLanguages) + + if let legacyApiKey = try container.decodeIfPresent(String.self, forKey: .apiKey), !legacyApiKey.isEmpty { + APIKeyManager.shared.saveCustomModelAPIKey(legacyApiKey, forModelId: id) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(displayName, forKey: .displayName) + try container.encode(description, forKey: .description) + try container.encode(apiEndpoint, forKey: .apiEndpoint) + try container.encode(modelName, forKey: .modelName) + try container.encode(isMultilingualModel, forKey: .isMultilingualModel) + try container.encode(supportedLanguages, forKey: .supportedLanguages) + } } struct LocalModel: TranscriptionModel { diff --git a/VoiceInk/Services/AIEnhancement/AIService.swift b/VoiceInk/Services/AIEnhancement/AIService.swift index 79bd128..3c69418 100644 --- a/VoiceInk/Services/AIEnhancement/AIService.swift +++ b/VoiceInk/Services/AIEnhancement/AIService.swift @@ -2,7 +2,7 @@ import Foundation enum AIProvider: String, CaseIterable { case cerebras = "Cerebras" - case groq = "GROQ" + case groq = "Groq" case gemini = "Gemini" case anthropic = "Anthropic" case openAI = "OpenAI" @@ -167,7 +167,7 @@ class AIService: ObservableObject { didSet { userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider") if selectedProvider.requiresAPIKey { - if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) { self.apiKey = savedKey self.isAPIKeyValid = true } else { @@ -199,7 +199,7 @@ class AIService: ObservableObject { if provider == .ollama { return ollamaService.isConnected } else if provider.requiresAPIKey { - return userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil + return APIKeyManager.shared.hasAPIKey(forProvider: provider.rawValue) } return false } @@ -224,22 +224,27 @@ class AIService: ObservableObject { } init() { + // Migrate legacy "GROQ" raw value to "Groq" + if userDefaults.string(forKey: "selectedAIProvider") == "GROQ" { + userDefaults.set("Groq", forKey: "selectedAIProvider") + } + if let savedProvider = userDefaults.string(forKey: "selectedAIProvider"), let provider = AIProvider(rawValue: savedProvider) { self.selectedProvider = provider } else { self.selectedProvider = .gemini } - + if selectedProvider.requiresAPIKey { - if let savedKey = userDefaults.string(forKey: "\(selectedProvider.rawValue)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: selectedProvider.rawValue) { self.apiKey = savedKey self.isAPIKeyValid = true } } else { self.isAPIKeyValid = true } - + loadSavedModelSelections() loadSavedOpenRouterModels() } @@ -283,14 +288,14 @@ class AIService: ObservableObject { completion(true, nil) return } - + verifyAPIKey(key) { [weak self] isValid, errorMessage in guard let self = self else { return } DispatchQueue.main.async { if isValid { self.apiKey = key self.isAPIKeyValid = true - self.userDefaults.set(key, forKey: "\(self.selectedProvider.rawValue)APIKey") + APIKeyManager.shared.saveAPIKey(key, forProvider: self.selectedProvider.rawValue) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) } else { self.isAPIKeyValid = false @@ -515,10 +520,10 @@ class AIService: ObservableObject { func clearAPIKey() { guard selectedProvider.requiresAPIKey else { return } - + apiKey = "" isAPIKeyValid = false - userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey") + APIKeyManager.shared.deleteAPIKey(forProvider: selectedProvider.rawValue) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) } diff --git a/VoiceInk/Services/APIKeyManager.swift b/VoiceInk/Services/APIKeyManager.swift new file mode 100644 index 0000000..06b9e4b --- /dev/null +++ b/VoiceInk/Services/APIKeyManager.swift @@ -0,0 +1,225 @@ +import Foundation +import os + +/// Manages API keys using secure Keychain storage with automatic migration from UserDefaults. +final class APIKeyManager { + static let shared = APIKeyManager() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "APIKeyManager") + private let keychain = KeychainService.shared + private let userDefaults = UserDefaults.standard + + private let migrationCompletedKey = "APIKeyMigrationToKeychainCompleted_v2" + + /// Provider to Keychain identifier mapping (iOS compatible for iCloud sync). + private static let providerToKeychainKey: [String: String] = [ + "groq": "groqAPIKey", + "deepgram": "deepgramAPIKey", + "cerebras": "cerebrasAPIKey", + "gemini": "geminiAPIKey", + "mistral": "mistralAPIKey", + "elevenlabs": "elevenLabsAPIKey", + "soniox": "sonioxAPIKey", + "openai": "openAIAPIKey", + "anthropic": "anthropicAPIKey", + "openrouter": "openRouterAPIKey" + ] + + /// Legacy UserDefaults to Keychain key mapping for migration. + private static let userDefaultsToKeychainMapping: [String: String] = [ + "GROQAPIKey": "groqAPIKey", + "DeepgramAPIKey": "deepgramAPIKey", + "CerebrasAPIKey": "cerebrasAPIKey", + "GeminiAPIKey": "geminiAPIKey", + "MistralAPIKey": "mistralAPIKey", + "ElevenLabsAPIKey": "elevenLabsAPIKey", + "SonioxAPIKey": "sonioxAPIKey", + "OpenAIAPIKey": "openAIAPIKey", + "AnthropicAPIKey": "anthropicAPIKey", + "OpenRouterAPIKey": "openRouterAPIKey" + ] + + private init() { + migrateFromUserDefaultsIfNeeded() + } + + // MARK: - Standard Provider API Keys + + /// Saves an API key for a provider. + @discardableResult + func saveAPIKey(_ key: String, forProvider provider: String) -> Bool { + let keyIdentifier = keychainIdentifier(forProvider: provider) + let success = keychain.save(key, forKey: keyIdentifier) + if success { + logger.info("Saved API key for provider: \(provider) with key: \(keyIdentifier)") + // Clean up any remaining UserDefaults entries (both old and new format) + cleanupUserDefaultsForProvider(provider) + } + return success + } + + /// Retrieves an API key for a provider. + func getAPIKey(forProvider provider: String) -> String? { + let keyIdentifier = keychainIdentifier(forProvider: provider) + + // First try Keychain with new identifier + if let key = keychain.getString(forKey: keyIdentifier), !key.isEmpty { + return key + } + + let oldKey = oldUserDefaultsKey(forProvider: provider) + if let key = userDefaults.string(forKey: oldKey), !key.isEmpty { + logger.info("Migrating \(oldKey) to Keychain") + keychain.save(key, forKey: keyIdentifier) + userDefaults.removeObject(forKey: oldKey) + return key + } + + return nil + } + + /// Deletes an API key for a provider. + @discardableResult + func deleteAPIKey(forProvider provider: String) -> Bool { + let keyIdentifier = keychainIdentifier(forProvider: provider) + let success = keychain.delete(forKey: keyIdentifier) + cleanupUserDefaultsForProvider(provider) + if success { + logger.info("Deleted API key for provider: \(provider)") + } + return success + } + + /// Checks if an API key exists for a provider. + func hasAPIKey(forProvider provider: String) -> Bool { + return getAPIKey(forProvider: provider) != nil + } + + // MARK: - Custom Model API Keys + + /// Saves an API key for a custom model. + @discardableResult + func saveCustomModelAPIKey(_ key: String, forModelId modelId: UUID) -> Bool { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + let success = keychain.save(key, forKey: keyIdentifier) + if success { + logger.info("Saved API key for custom model: \(modelId.uuidString)") + } + return success + } + + /// Retrieves an API key for a custom model. + func getCustomModelAPIKey(forModelId modelId: UUID) -> String? { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + return keychain.getString(forKey: keyIdentifier) + } + + /// Deletes an API key for a custom model. + @discardableResult + func deleteCustomModelAPIKey(forModelId modelId: UUID) -> Bool { + let keyIdentifier = customModelKeyIdentifier(for: modelId) + let success = keychain.delete(forKey: keyIdentifier) + if success { + logger.info("Deleted API key for custom model: \(modelId.uuidString)") + } + return success + } + + // MARK: - Migration + + /// Migrates API keys from UserDefaults to Keychain on first run. + private func migrateFromUserDefaultsIfNeeded() { + if userDefaults.bool(forKey: migrationCompletedKey) { + return + } + + logger.info("Starting API key migration") + var migratedCount = 0 + + for (oldKey, newKey) in Self.userDefaultsToKeychainMapping { + if let value = userDefaults.string(forKey: oldKey), !value.isEmpty { + if keychain.save(value, forKey: newKey) { + userDefaults.removeObject(forKey: oldKey) + migratedCount += 1 + } else { + logger.error("Failed to migrate \(oldKey)") + } + } + } + + migrateCustomModelAPIKeys() + userDefaults.set(true, forKey: migrationCompletedKey) + logger.info("Migration completed. Migrated \(migratedCount) API keys.") + } + + /// Migrates custom model API keys from UserDefaults. + private func migrateCustomModelAPIKeys() { + guard let data = userDefaults.data(forKey: "customCloudModels") else { + return + } + + struct LegacyCustomCloudModel: Codable { + let id: UUID + let apiKey: String + } + + do { + let legacyModels = try JSONDecoder().decode([LegacyCustomCloudModel].self, from: data) + for model in legacyModels where !model.apiKey.isEmpty { + let keyIdentifier = customModelKeyIdentifier(for: model.id) + keychain.save(model.apiKey, forKey: keyIdentifier) + } + } catch { + logger.error("Failed to decode legacy custom models: \(error.localizedDescription)") + } + } + + // MARK: - Key Identifier Helpers + + /// Returns Keychain identifier for a provider (case-insensitive). + private func keychainIdentifier(forProvider provider: String) -> String { + let lowercased = provider.lowercased() + if let mapped = Self.providerToKeychainKey[lowercased] { + return mapped + } + return "\(lowercased)APIKey" + } + + /// Returns old UserDefaults key for provider (pre-Keychain format). + private func oldUserDefaultsKey(forProvider provider: String) -> String { + switch provider.lowercased() { + case "groq": + return "GROQAPIKey" + case "deepgram": + return "DeepgramAPIKey" + case "cerebras": + return "CerebrasAPIKey" + case "gemini": + return "GeminiAPIKey" + case "mistral": + return "MistralAPIKey" + case "elevenlabs": + return "ElevenLabsAPIKey" + case "soniox": + return "SonioxAPIKey" + case "openai": + return "OpenAIAPIKey" + case "anthropic": + return "AnthropicAPIKey" + case "openrouter": + return "OpenRouterAPIKey" + default: + return "\(provider)APIKey" + } + } + + /// Cleans up UserDefaults entries for a provider. + private func cleanupUserDefaultsForProvider(_ provider: String) { + userDefaults.removeObject(forKey: oldUserDefaultsKey(forProvider: provider)) + } + + /// Generates Keychain identifier for custom model API key. + private func customModelKeyIdentifier(for modelId: UUID) -> String { + "customModel_\(modelId.uuidString)_APIKey" + } +} diff --git a/VoiceInk/Services/CloudTranscription/CustomModelManager.swift b/VoiceInk/Services/CloudTranscription/CustomModelManager.swift index 41886f5..f62257c 100644 --- a/VoiceInk/Services/CloudTranscription/CustomModelManager.swift +++ b/VoiceInk/Services/CloudTranscription/CustomModelManager.swift @@ -25,6 +25,7 @@ class CustomModelManager: ObservableObject { func removeCustomModel(withId id: UUID) { customModels.removeAll { $0.id == id } saveCustomModels() + APIKeyManager.shared.deleteCustomModelAPIKey(forModelId: id) logger.info("Removed custom model with ID: \(id)") } diff --git a/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift index 8d0a4fc..d27cf62 100644 --- a/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/DeepgramTranscriptionService.swift @@ -42,7 +42,7 @@ class DeepgramTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "DeepgramAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Deepgram"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift index f2ede32..2c24164 100644 --- a/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/ElevenLabsTranscriptionService.swift @@ -6,7 +6,7 @@ class ElevenLabsTranscriptionService { private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService") func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { - guard let apiKey = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "ElevenLabs"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift index 543a773..80f79ab 100644 --- a/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/GeminiTranscriptionService.swift @@ -75,15 +75,15 @@ class GeminiTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "GeminiAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Gemini"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } - + let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent" guard let apiURL = URL(string: urlString) else { throw CloudTranscriptionError.dataEncodingError } - + return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name) } diff --git a/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift index daf3da5..42fbda3 100644 --- a/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/GroqTranscriptionService.swift @@ -102,10 +102,10 @@ class GroqTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "GROQAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Groq"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } - + guard let apiURL = URL(string: "https://api.groq.com/openai/v1/audio/transcriptions") else { throw NSError(domain: "GroqTranscriptionService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"]) } diff --git a/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift index f98d02d..9979836 100644 --- a/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/MistralTranscriptionService.swift @@ -6,8 +6,7 @@ class MistralTranscriptionService { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { logger.notice("Sending transcription request to Mistral for model: \(model.name)") - let apiKey = UserDefaults.standard.string(forKey: "MistralAPIKey") ?? "" - guard !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Mistral"), !apiKey.isEmpty else { logger.error("Mistral API key is missing.") throw CloudTranscriptionError.missingAPIKey } diff --git a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift index 95cece6..f5a7647 100644 --- a/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift +++ b/VoiceInk/Services/CloudTranscription/SonioxTranscriptionService.swift @@ -24,7 +24,7 @@ class SonioxTranscriptionService { } private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { - guard let apiKey = UserDefaults.standard.string(forKey: "SonioxAPIKey"), !apiKey.isEmpty else { + guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Soniox"), !apiKey.isEmpty else { throw CloudTranscriptionError.missingAPIKey } return APIConfig(apiKey: apiKey) diff --git a/VoiceInk/Services/KeychainService.swift b/VoiceInk/Services/KeychainService.swift new file mode 100644 index 0000000..cc74734 --- /dev/null +++ b/VoiceInk/Services/KeychainService.swift @@ -0,0 +1,115 @@ +import Foundation +import Security +import os + +/// Securely stores and retrieves API keys using Keychain with iCloud sync. +final class KeychainService { + static let shared = KeychainService() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "KeychainService") + private let service = "com.prakashjoshipax.VoiceInk" + + private init() {} + + // MARK: - Public API + + /// Saves a string value to Keychain. + @discardableResult + func save(_ value: String, forKey key: String, syncable: Bool = true) -> Bool { + guard let data = value.data(using: .utf8) else { + logger.error("Failed to convert value to data for key: \(key)") + return false + } + return save(data: data, forKey: key, syncable: syncable) + } + + /// Saves data to Keychain. + @discardableResult + func save(data: Data, forKey key: String, syncable: Bool = true) -> Bool { + // First, try to delete any existing item to avoid duplicates + delete(forKey: key, syncable: syncable) + + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecValueData as String] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + logger.info("Successfully saved keychain item for key: \(key)") + return true + } else { + logger.error("Failed to save keychain item for key: \(key), status: \(status)") + return false + } + } + + /// Retrieves a string value from Keychain. + func getString(forKey key: String, syncable: Bool = true) -> String? { + guard let data = getData(forKey: key, syncable: syncable) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + /// Retrieves data from Keychain. + func getData(forKey key: String, syncable: Bool = true) -> Data? { + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecReturnData as String] = kCFBooleanTrue + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess { + return result as? Data + } else if status != errSecItemNotFound { + logger.error("Failed to retrieve keychain item for key: \(key), status: \(status)") + } + + return nil + } + + /// Deletes an item from Keychain. + @discardableResult + func delete(forKey key: String, syncable: Bool = true) -> Bool { + let query = baseQuery(forKey: key, syncable: syncable) + let status = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess || status == errSecItemNotFound { + if status == errSecSuccess { + logger.info("Successfully deleted keychain item for key: \(key)") + } + return true + } else { + logger.error("Failed to delete keychain item for key: \(key), status: \(status)") + return false + } + } + + /// Checks if a key exists in Keychain. + func exists(forKey key: String, syncable: Bool = true) -> Bool { + var query = baseQuery(forKey: key, syncable: syncable) + query[kSecReturnData as String] = kCFBooleanFalse + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } + + // MARK: - Private Helpers + + /// Creates base Keychain query dictionary. + private func baseQuery(forKey key: String, syncable: Bool) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecUseDataProtectionKeychain as String: true + ] + + if syncable { + query[kSecAttrSynchronizable as String] = kCFBooleanTrue + } + + return query + } +} diff --git a/VoiceInk/Services/LicenseManager.swift b/VoiceInk/Services/LicenseManager.swift new file mode 100644 index 0000000..675736f --- /dev/null +++ b/VoiceInk/Services/LicenseManager.swift @@ -0,0 +1,125 @@ +import Foundation +import os + +/// Manages license data using secure Keychain storage (non-syncable, device-local). +final class LicenseManager { + static let shared = LicenseManager() + + private let keychain = KeychainService.shared + private let userDefaults = UserDefaults.standard + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "LicenseManager") + + private let licenseKeyIdentifier = "voiceink.license.key" + private let trialStartDateIdentifier = "voiceink.license.trialStartDate" + private let activationIdIdentifier = "voiceink.license.activationId" + private let migrationCompletedKey = "LicenseKeychainMigrationCompleted" + + private init() { + migrateFromUserDefaultsIfNeeded() + } + + // MARK: - License Key + + var licenseKey: String? { + get { keychain.getString(forKey: licenseKeyIdentifier, syncable: false) } + set { + if let value = newValue { + keychain.save(value, forKey: licenseKeyIdentifier, syncable: false) + } else { + keychain.delete(forKey: licenseKeyIdentifier, syncable: false) + } + } + } + + // MARK: - Trial Start Date + + var trialStartDate: Date? { + get { + guard let data = keychain.getData(forKey: trialStartDateIdentifier, syncable: false), + let timestamp = String(data: data, encoding: .utf8), + let timeInterval = Double(timestamp) else { + return nil + } + return Date(timeIntervalSince1970: timeInterval) + } + set { + if let date = newValue { + let timestamp = String(date.timeIntervalSince1970) + keychain.save(timestamp, forKey: trialStartDateIdentifier, syncable: false) + } else { + keychain.delete(forKey: trialStartDateIdentifier, syncable: false) + } + } + } + + // MARK: - Activation ID + + var activationId: String? { + get { keychain.getString(forKey: activationIdIdentifier, syncable: false) } + set { + if let value = newValue { + keychain.save(value, forKey: activationIdIdentifier, syncable: false) + } else { + keychain.delete(forKey: activationIdIdentifier, syncable: false) + } + } + } + + // MARK: - Migration + + private func migrateFromUserDefaultsIfNeeded() { + guard !userDefaults.bool(forKey: migrationCompletedKey) else { return } + + // Migrate license key + if let oldLicenseKey = userDefaults.string(forKey: "VoiceInkLicense"), !oldLicenseKey.isEmpty { + licenseKey = oldLicenseKey + userDefaults.removeObject(forKey: "VoiceInkLicense") + logger.info("Migrated license key to Keychain") + } + + // Migrate trial start date (from obfuscated storage) + if let oldTrialDate = getObfuscatedTrialStartDate() { + trialStartDate = oldTrialDate + clearObfuscatedTrialStartDate() + logger.info("Migrated trial start date to Keychain") + } + + // Migrate activation ID + if let oldActivationId = userDefaults.string(forKey: "VoiceInkActivationId"), !oldActivationId.isEmpty { + activationId = oldActivationId + userDefaults.removeObject(forKey: "VoiceInkActivationId") + logger.info("Migrated activation ID to Keychain") + } + + userDefaults.set(true, forKey: migrationCompletedKey) + logger.info("License migration completed") + } + + /// Reads the old obfuscated trial start date from UserDefaults. + private func getObfuscatedTrialStartDate() -> Date? { + let salt = Obfuscator.getDeviceIdentifier() + let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt) + + guard let obfuscatedValue = userDefaults.string(forKey: obfuscatedKey), + let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt), + let timestamp = Double(decodedValue) else { + return nil + } + + return Date(timeIntervalSince1970: timestamp) + } + + /// Clears the old obfuscated trial start date from UserDefaults. + private func clearObfuscatedTrialStartDate() { + let salt = Obfuscator.getDeviceIdentifier() + let obfuscatedKey = Obfuscator.encode("VoiceInkTrialStartDate", salt: salt) + userDefaults.removeObject(forKey: obfuscatedKey) + } + + /// Removes all license data (for license removal/reset). + func removeAll() { + licenseKey = nil + trialStartDate = nil + activationId = nil + } +} diff --git a/VoiceInk/Services/SystemInfoService.swift b/VoiceInk/Services/SystemInfoService.swift index 490c2b4..57d3ca2 100644 --- a/VoiceInk/Services/SystemInfoService.swift +++ b/VoiceInk/Services/SystemInfoService.swift @@ -188,11 +188,11 @@ class SystemInfoService { } private func getLicenseStatus() -> String { - let userDefaults = UserDefaults.standard + let licenseManager = LicenseManager.shared // Check for existing license key and activation - if let _ = userDefaults.licenseKey { - if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { + if licenseManager.licenseKey != nil { + if licenseManager.activationId != nil || !UserDefaults.standard.bool(forKey: "VoiceInkLicenseRequiresActivation") { return "Licensed (Pro)" } } diff --git a/VoiceInk/Services/UserDefaultsManager.swift b/VoiceInk/Services/UserDefaultsManager.swift index 88a9895..29aad14 100644 --- a/VoiceInk/Services/UserDefaultsManager.swift +++ b/VoiceInk/Services/UserDefaultsManager.swift @@ -2,58 +2,10 @@ import Foundation extension UserDefaults { enum Keys { - static let aiProviderApiKey = "VoiceInkAIProviderKey" - static let licenseKey = "VoiceInkLicense" - static let trialStartDate = "VoiceInkTrialStartDate" static let audioInputMode = "audioInputMode" static let selectedAudioDeviceUID = "selectedAudioDeviceUID" static let prioritizedDevices = "prioritizedDevices" static let affiliatePromotionDismissed = "VoiceInkAffiliatePromotionDismissed" - - // Obfuscated keys for license-related data - enum License { - static let trialStartDate = "VoiceInkTrialStartDate" - } - } - - // MARK: - AI Provider API Key - var aiProviderApiKey: String? { - get { string(forKey: Keys.aiProviderApiKey) } - set { setValue(newValue, forKey: Keys.aiProviderApiKey) } - } - - // MARK: - License Key - var licenseKey: String? { - get { string(forKey: Keys.licenseKey) } - set { setValue(newValue, forKey: Keys.licenseKey) } - } - - // MARK: - Trial Start Date (Obfuscated) - var trialStartDate: Date? { - get { - let salt = Obfuscator.getDeviceIdentifier() - let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt) - - guard let obfuscatedValue = string(forKey: obfuscatedKey), - let decodedValue = Obfuscator.decode(obfuscatedValue, salt: salt), - let timestamp = Double(decodedValue) else { - return nil - } - - return Date(timeIntervalSince1970: timestamp) - } - set { - let salt = Obfuscator.getDeviceIdentifier() - let obfuscatedKey = Obfuscator.encode(Keys.License.trialStartDate, salt: salt) - - if let date = newValue { - let timestamp = String(date.timeIntervalSince1970) - let obfuscatedValue = Obfuscator.encode(timestamp, salt: salt) - setValue(obfuscatedValue, forKey: obfuscatedKey) - } else { - removeObject(forKey: obfuscatedKey) - } - } } // MARK: - Audio Input Mode @@ -79,4 +31,4 @@ extension UserDefaults { get { bool(forKey: Keys.affiliatePromotionDismissed) } set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) } } -} \ No newline at end of file +} diff --git a/VoiceInk/Views/AI Models/AddCustomModelView.swift b/VoiceInk/Views/AI Models/AddCustomModelView.swift index d2b87ee..a78551e 100644 --- a/VoiceInk/Views/AI Models/AddCustomModelView.swift +++ b/VoiceInk/Views/AI Models/AddCustomModelView.swift @@ -223,39 +223,49 @@ struct AddCustomModelCardView: View { } isSaving = true - - // Simulate a brief save operation for better UX + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { if let editing = editingModel { - // Update existing model let updatedModel = CustomCloudModel( id: editing.id, name: generatedName, displayName: trimmedDisplayName, description: "Custom transcription model", apiEndpoint: trimmedApiEndpoint, - apiKey: trimmedApiKey, modelName: trimmedModelName, isMultilingual: isMultilingual ) - customModelManager.updateCustomModel(updatedModel) + + if APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id) { + customModelManager.updateCustomModel(updatedModel) + } else { + validationErrors = ["Failed to securely save API Key to Keychain. Please check your system settings or try again."] + showingAlert = true + isSaving = false + return + } } else { - // Add new model let customModel = CustomCloudModel( name: generatedName, displayName: trimmedDisplayName, description: "Custom transcription model", apiEndpoint: trimmedApiEndpoint, - apiKey: trimmedApiKey, modelName: trimmedModelName, isMultilingual: isMultilingual ) - customModelManager.addCustomModel(customModel) + + if APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id) { + customModelManager.addCustomModel(customModel) + } else { + validationErrors = ["Failed to securely save API Key to Keychain. Please check your system settings or try again."] + showingAlert = true + isSaving = false + return + } } - + onModelAdded() - - // Reset form and collapse + withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) { isExpanded = false clearForm() diff --git a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift index 4db2b19..6d7bd61 100644 --- a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift +++ b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift @@ -21,16 +21,13 @@ struct CloudModelCardView: View { } private var isConfigured: Bool { - guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else { - return false - } - return !savedKey.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: providerKey) } private var providerKey: String { switch model.provider { case .groq: - return "GROQ" + return "Groq" case .elevenLabs: return "ElevenLabs" case .deepgram: @@ -267,7 +264,7 @@ struct CloudModelCardView: View { } private func loadSavedAPIKey() { - if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") { + if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: providerKey) { apiKey = savedKey verificationStatus = .success } @@ -306,10 +303,10 @@ struct CloudModelCardView: View { if isValid { self.verificationStatus = .success self.verificationError = nil - // Save the API key - UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey") + // Save the API key to Keychain + APIKeyManager.shared.saveAPIKey(self.apiKey, forProvider: self.providerKey) self.isConfiguredState = true - + // Collapse the configuration section after successful verification withAnimation(.easeInOut(duration: 0.3)) { self.isExpanded = false @@ -318,20 +315,17 @@ struct CloudModelCardView: View { self.verificationStatus = .failure self.verificationError = errorMessage } - - // Restore original provider - // aiService.selectedProvider = originalProvider // This line was removed as per the new_code } } } private func clearAPIKey() { - UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey") + APIKeyManager.shared.deleteAPIKey(forProvider: providerKey) apiKey = "" verificationStatus = .none verificationError = nil isConfiguredState = false - + // If this model is currently the default, clear it if isCurrent { Task { @@ -341,7 +335,7 @@ struct CloudModelCardView: View { } } } - + withAnimation(.easeInOut(duration: 0.3)) { isExpanded = false } diff --git a/VoiceInk/Whisper/WhisperState+ModelQueries.swift b/VoiceInk/Whisper/WhisperState+ModelQueries.swift index f634ea0..ff92be3 100644 --- a/VoiceInk/Whisper/WhisperState+ModelQueries.swift +++ b/VoiceInk/Whisper/WhisperState+ModelQueries.swift @@ -15,23 +15,17 @@ extension WhisperState { return false } case .groq: - let key = UserDefaults.standard.string(forKey: "GROQAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Groq") case .elevenLabs: - let key = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "ElevenLabs") case .deepgram: - let key = UserDefaults.standard.string(forKey: "DeepgramAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Deepgram") case .mistral: - let key = UserDefaults.standard.string(forKey: "MistralAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Mistral") case .gemini: - let key = UserDefaults.standard.string(forKey: "GeminiAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Gemini") case .soniox: - let key = UserDefaults.standard.string(forKey: "SonioxAPIKey") - return key != nil && !key!.isEmpty + return APIKeyManager.shared.hasAPIKey(forProvider: "Soniox") case .custom: // Custom models are always usable since they contain their own API keys return true