Merge pull request #475 from Beingpax/secure-keychain-storage

Secure keychain storage
This commit is contained in:
Prakash Joshi Pax 2026-01-06 14:25:52 +05:45 committed by GitHub
commit 8d06068145
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 607 additions and 155 deletions

View File

@ -8,44 +8,45 @@ class LicenseViewModel: ObservableObject {
case trialExpired case trialExpired
case licensed case licensed
} }
@Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial @Published private(set) var licenseState: LicenseState = .trial(daysRemaining: 7) // Default to trial
@Published var licenseKey: String = "" @Published var licenseKey: String = ""
@Published var isValidating = false @Published var isValidating = false
@Published var validationMessage: String? @Published var validationMessage: String?
@Published private(set) var activationsLimit: Int = 0 @Published private(set) var activationsLimit: Int = 0
private let trialPeriodDays = 7 private let trialPeriodDays = 7
private let polarService = PolarService() private let polarService = PolarService()
private let userDefaults = UserDefaults.standard private let userDefaults = UserDefaults.standard
private let licenseManager = LicenseManager.shared
init() { init() {
loadLicenseState() loadLicenseState()
} }
func startTrial() { func startTrial() {
// Only set trial start date if it hasn't been set before // Only set trial start date if it hasn't been set before
if userDefaults.trialStartDate == nil { if licenseManager.trialStartDate == nil {
userDefaults.trialStartDate = Date() licenseManager.trialStartDate = Date()
licenseState = .trial(daysRemaining: trialPeriodDays) licenseState = .trial(daysRemaining: trialPeriodDays)
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
} }
} }
private func loadLicenseState() { private func loadLicenseState() {
// Check for existing license key // Check for existing license key
if let licenseKey = userDefaults.licenseKey { if let storedLicenseKey = licenseManager.licenseKey {
self.licenseKey = licenseKey self.licenseKey = storedLicenseKey
// If we have a license key, trust that it's licensed // If we have a license key, trust that it's licensed
// Skip server validation on startup // Skip server validation on startup
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { if licenseManager.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") {
licenseState = .licensed licenseState = .licensed
activationsLimit = userDefaults.activationsLimit activationsLimit = userDefaults.activationsLimit
return return
} }
} }
// Check if this is first launch // Check if this is first launch
let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore") let hasLaunchedBefore = userDefaults.bool(forKey: "VoiceInkHasLaunchedBefore")
if !hasLaunchedBefore { if !hasLaunchedBefore {
@ -54,11 +55,11 @@ class LicenseViewModel: ObservableObject {
startTrial() startTrial()
return return
} }
// Only check trial if not licensed and not first launch // 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 let daysSinceTrialStart = Calendar.current.dateComponents([.day], from: trialStartDate, to: Date()).day ?? 0
if daysSinceTrialStart >= trialPeriodDays { if daysSinceTrialStart >= trialPeriodDays {
licenseState = .trialExpired licenseState = .trialExpired
} else { } else {
@ -104,13 +105,13 @@ class LicenseViewModel: ObservableObject {
} }
// Store the license key // Store the license key
userDefaults.licenseKey = licenseKey licenseManager.licenseKey = licenseKey
// Handle based on whether activation is required // Handle based on whether activation is required
if licenseCheck.requiresActivation { if licenseCheck.requiresActivation {
// If we already have an activation ID, validate with it // If we already have an activation ID, validate with it
if let activationId = userDefaults.activationId { if let existingActivationId = licenseManager.activationId {
let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: activationId) let isValid = try await polarService.validateLicenseKeyWithActivation(licenseKey, activationId: existingActivationId)
if isValid { if isValid {
// Existing activation is valid // Existing activation is valid
licenseState = .licensed licenseState = .licensed
@ -120,23 +121,23 @@ class LicenseViewModel: ObservableObject {
return return
} }
} }
// Need to create a new activation // 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 // Store activation details
userDefaults.activationId = activationId licenseManager.activationId = newActivationId
userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation") userDefaults.set(true, forKey: "VoiceInkLicenseRequiresActivation")
self.activationsLimit = limit self.activationsLimit = limit
userDefaults.activationsLimit = limit userDefaults.activationsLimit = limit
} else { } else {
// This license doesn't require activation (unlimited devices) // This license doesn't require activation (unlimited devices)
userDefaults.activationId = nil licenseManager.activationId = nil
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
self.activationsLimit = licenseCheck.activationsLimit ?? 0 self.activationsLimit = licenseCheck.activationsLimit ?? 0
userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0 userDefaults.activationsLimit = licenseCheck.activationsLimit ?? 0
// Update the license state for unlimited license // Update the license state for unlimited license
licenseState = .licensed licenseState = .licensed
validationMessage = "License validated successfully!" validationMessage = "License validated successfully!"
@ -154,12 +155,12 @@ class LicenseViewModel: ObservableObject {
validationMessage = "Activation limit reached: \(details)" validationMessage = "Activation limit reached: \(details)"
} catch LicenseError.activationNotRequired { } catch LicenseError.activationNotRequired {
// This is actually a success case for unlimited licenses // This is actually a success case for unlimited licenses
userDefaults.licenseKey = licenseKey licenseManager.licenseKey = licenseKey
userDefaults.activationId = nil licenseManager.activationId = nil
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
self.activationsLimit = 0 self.activationsLimit = 0
userDefaults.activationsLimit = 0 userDefaults.activationsLimit = 0
licenseState = .licensed licenseState = .licensed
validationMessage = "License activated successfully!" validationMessage = "License activated successfully!"
NotificationCenter.default.post(name: .licenseStatusChanged, object: nil) NotificationCenter.default.post(name: .licenseStatusChanged, object: nil)
@ -171,15 +172,14 @@ class LicenseViewModel: ObservableObject {
} }
func removeLicense() { func removeLicense() {
// Remove both license key and trial data // Remove all license data from Keychain
userDefaults.licenseKey = nil licenseManager.removeAll()
userDefaults.activationId = nil
// Reset UserDefaults flags
userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation") userDefaults.set(false, forKey: "VoiceInkLicenseRequiresActivation")
userDefaults.trialStartDate = nil
userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart userDefaults.set(false, forKey: "VoiceInkHasLaunchedBefore") // Allow trial to restart
userDefaults.activationsLimit = 0 userDefaults.activationsLimit = 0
licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state licenseState = .trial(daysRemaining: trialPeriodDays) // Reset to trial state
licenseKey = "" licenseKey = ""
validationMessage = nil 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 { extension UserDefaults {
var activationId: String? {
get { string(forKey: "VoiceInkActivationId") }
set { set(newValue, forKey: "VoiceInkActivationId") }
}
var activationsLimit: Int { var activationsLimit: Int {
get { integer(forKey: "VoiceInkActivationsLimit") } get { integer(forKey: "VoiceInkActivationsLimit") }
set { set(newValue, forKey: "VoiceInkActivationsLimit") } set { set(newValue, forKey: "VoiceInkActivationsLimit") }

View File

@ -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 { struct CustomCloudModel: TranscriptionModel, Codable {
let id: UUID let id: UUID
let name: String let name: String
@ -99,22 +99,59 @@ struct CustomCloudModel: TranscriptionModel, Codable {
let description: String let description: String
let provider: ModelProvider = .custom let provider: ModelProvider = .custom
let apiEndpoint: String let apiEndpoint: String
let apiKey: String
let modelName: String let modelName: String
let isMultilingualModel: Bool let isMultilingualModel: Bool
let supportedLanguages: [String: String] 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.id = id
self.name = name self.name = name
self.displayName = displayName self.displayName = displayName
self.description = description self.description = description
self.apiEndpoint = apiEndpoint self.apiEndpoint = apiEndpoint
self.apiKey = apiKey
self.modelName = modelName self.modelName = modelName
self.isMultilingualModel = isMultilingual self.isMultilingualModel = isMultilingual
self.supportedLanguages = supportedLanguages ?? PredefinedModels.getLanguageDictionary(isMultilingual: 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 { struct LocalModel: TranscriptionModel {

View File

@ -2,7 +2,7 @@ import Foundation
enum AIProvider: String, CaseIterable { enum AIProvider: String, CaseIterable {
case cerebras = "Cerebras" case cerebras = "Cerebras"
case groq = "GROQ" case groq = "Groq"
case gemini = "Gemini" case gemini = "Gemini"
case anthropic = "Anthropic" case anthropic = "Anthropic"
case openAI = "OpenAI" case openAI = "OpenAI"
@ -167,7 +167,7 @@ class AIService: ObservableObject {
didSet { didSet {
userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider") userDefaults.set(selectedProvider.rawValue, forKey: "selectedAIProvider")
if selectedProvider.requiresAPIKey { 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.apiKey = savedKey
self.isAPIKeyValid = true self.isAPIKeyValid = true
} else { } else {
@ -199,7 +199,7 @@ class AIService: ObservableObject {
if provider == .ollama { if provider == .ollama {
return ollamaService.isConnected return ollamaService.isConnected
} else if provider.requiresAPIKey { } else if provider.requiresAPIKey {
return userDefaults.string(forKey: "\(provider.rawValue)APIKey") != nil return APIKeyManager.shared.hasAPIKey(forProvider: provider.rawValue)
} }
return false return false
} }
@ -224,22 +224,27 @@ class AIService: ObservableObject {
} }
init() { 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"), if let savedProvider = userDefaults.string(forKey: "selectedAIProvider"),
let provider = AIProvider(rawValue: savedProvider) { let provider = AIProvider(rawValue: savedProvider) {
self.selectedProvider = provider self.selectedProvider = provider
} else { } else {
self.selectedProvider = .gemini self.selectedProvider = .gemini
} }
if selectedProvider.requiresAPIKey { 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.apiKey = savedKey
self.isAPIKeyValid = true self.isAPIKeyValid = true
} }
} else { } else {
self.isAPIKeyValid = true self.isAPIKeyValid = true
} }
loadSavedModelSelections() loadSavedModelSelections()
loadSavedOpenRouterModels() loadSavedOpenRouterModels()
} }
@ -283,14 +288,14 @@ class AIService: ObservableObject {
completion(true, nil) completion(true, nil)
return return
} }
verifyAPIKey(key) { [weak self] isValid, errorMessage in verifyAPIKey(key) { [weak self] isValid, errorMessage in
guard let self = self else { return } guard let self = self else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
if isValid { if isValid {
self.apiKey = key self.apiKey = key
self.isAPIKeyValid = true 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) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
} else { } else {
self.isAPIKeyValid = false self.isAPIKeyValid = false
@ -515,10 +520,10 @@ class AIService: ObservableObject {
func clearAPIKey() { func clearAPIKey() {
guard selectedProvider.requiresAPIKey else { return } guard selectedProvider.requiresAPIKey else { return }
apiKey = "" apiKey = ""
isAPIKeyValid = false isAPIKeyValid = false
userDefaults.removeObject(forKey: "\(selectedProvider.rawValue)APIKey") APIKeyManager.shared.deleteAPIKey(forProvider: selectedProvider.rawValue)
NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil) NotificationCenter.default.post(name: .aiProviderKeyChanged, object: nil)
} }

View File

@ -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"
}
}

View File

@ -25,6 +25,7 @@ class CustomModelManager: ObservableObject {
func removeCustomModel(withId id: UUID) { func removeCustomModel(withId id: UUID) {
customModels.removeAll { $0.id == id } customModels.removeAll { $0.id == id }
saveCustomModels() saveCustomModels()
APIKeyManager.shared.deleteCustomModelAPIKey(forModelId: id)
logger.info("Removed custom model with ID: \(id)") logger.info("Removed custom model with ID: \(id)")
} }

View File

@ -42,7 +42,7 @@ class DeepgramTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { 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 throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -6,7 +6,7 @@ class ElevenLabsTranscriptionService {
private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService") private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ElevenLabsTranscriptionService")
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { 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 throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -75,15 +75,15 @@ class GeminiTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { 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 throw CloudTranscriptionError.missingAPIKey
} }
let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent" let urlString = "https://generativelanguage.googleapis.com/v1beta/models/\(model.name):generateContent"
guard let apiURL = URL(string: urlString) else { guard let apiURL = URL(string: urlString) else {
throw CloudTranscriptionError.dataEncodingError throw CloudTranscriptionError.dataEncodingError
} }
return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name) return APIConfig(url: apiURL, apiKey: apiKey, modelName: model.name)
} }

View File

@ -102,10 +102,10 @@ class GroqTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { 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 throw CloudTranscriptionError.missingAPIKey
} }
guard let apiURL = URL(string: "https://api.groq.com/openai/v1/audio/transcriptions") else { 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"]) throw NSError(domain: "GroqTranscriptionService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid API URL"])
} }

View File

@ -6,8 +6,7 @@ class MistralTranscriptionService {
func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String { func transcribe(audioURL: URL, model: any TranscriptionModel) async throws -> String {
logger.notice("Sending transcription request to Mistral for model: \(model.name)") logger.notice("Sending transcription request to Mistral for model: \(model.name)")
let apiKey = UserDefaults.standard.string(forKey: "MistralAPIKey") ?? "" guard let apiKey = APIKeyManager.shared.getAPIKey(forProvider: "Mistral"), !apiKey.isEmpty else {
guard !apiKey.isEmpty else {
logger.error("Mistral API key is missing.") logger.error("Mistral API key is missing.")
throw CloudTranscriptionError.missingAPIKey throw CloudTranscriptionError.missingAPIKey
} }

View File

@ -24,7 +24,7 @@ class SonioxTranscriptionService {
} }
private func getAPIConfig(for model: any TranscriptionModel) throws -> APIConfig { 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 throw CloudTranscriptionError.missingAPIKey
} }
return APIConfig(apiKey: apiKey) return APIConfig(apiKey: apiKey)

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -188,11 +188,11 @@ class SystemInfoService {
} }
private func getLicenseStatus() -> String { private func getLicenseStatus() -> String {
let userDefaults = UserDefaults.standard let licenseManager = LicenseManager.shared
// Check for existing license key and activation // Check for existing license key and activation
if let _ = userDefaults.licenseKey { if licenseManager.licenseKey != nil {
if userDefaults.activationId != nil || !userDefaults.bool(forKey: "VoiceInkLicenseRequiresActivation") { if licenseManager.activationId != nil || !UserDefaults.standard.bool(forKey: "VoiceInkLicenseRequiresActivation") {
return "Licensed (Pro)" return "Licensed (Pro)"
} }
} }

View File

@ -2,58 +2,10 @@ import Foundation
extension UserDefaults { extension UserDefaults {
enum Keys { enum Keys {
static let aiProviderApiKey = "VoiceInkAIProviderKey"
static let licenseKey = "VoiceInkLicense"
static let trialStartDate = "VoiceInkTrialStartDate"
static let audioInputMode = "audioInputMode" static let audioInputMode = "audioInputMode"
static let selectedAudioDeviceUID = "selectedAudioDeviceUID" static let selectedAudioDeviceUID = "selectedAudioDeviceUID"
static let prioritizedDevices = "prioritizedDevices" static let prioritizedDevices = "prioritizedDevices"
static let affiliatePromotionDismissed = "VoiceInkAffiliatePromotionDismissed" 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 // MARK: - Audio Input Mode
@ -79,4 +31,4 @@ extension UserDefaults {
get { bool(forKey: Keys.affiliatePromotionDismissed) } get { bool(forKey: Keys.affiliatePromotionDismissed) }
set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) } set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) }
} }
} }

View File

@ -223,39 +223,49 @@ struct AddCustomModelCardView: View {
} }
isSaving = true isSaving = true
// Simulate a brief save operation for better UX
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if let editing = editingModel { if let editing = editingModel {
// Update existing model
let updatedModel = CustomCloudModel( let updatedModel = CustomCloudModel(
id: editing.id, id: editing.id,
name: generatedName, name: generatedName,
displayName: trimmedDisplayName, displayName: trimmedDisplayName,
description: "Custom transcription model", description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint, apiEndpoint: trimmedApiEndpoint,
apiKey: trimmedApiKey,
modelName: trimmedModelName, modelName: trimmedModelName,
isMultilingual: isMultilingual 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 { } else {
// Add new model
let customModel = CustomCloudModel( let customModel = CustomCloudModel(
name: generatedName, name: generatedName,
displayName: trimmedDisplayName, displayName: trimmedDisplayName,
description: "Custom transcription model", description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint, apiEndpoint: trimmedApiEndpoint,
apiKey: trimmedApiKey,
modelName: trimmedModelName, modelName: trimmedModelName,
isMultilingual: isMultilingual 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() onModelAdded()
// Reset form and collapse
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) { withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = false isExpanded = false
clearForm() clearForm()

View File

@ -21,16 +21,13 @@ struct CloudModelCardView: View {
} }
private var isConfigured: Bool { private var isConfigured: Bool {
guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else { return APIKeyManager.shared.hasAPIKey(forProvider: providerKey)
return false
}
return !savedKey.isEmpty
} }
private var providerKey: String { private var providerKey: String {
switch model.provider { switch model.provider {
case .groq: case .groq:
return "GROQ" return "Groq"
case .elevenLabs: case .elevenLabs:
return "ElevenLabs" return "ElevenLabs"
case .deepgram: case .deepgram:
@ -267,7 +264,7 @@ struct CloudModelCardView: View {
} }
private func loadSavedAPIKey() { private func loadSavedAPIKey() {
if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") { if let savedKey = APIKeyManager.shared.getAPIKey(forProvider: providerKey) {
apiKey = savedKey apiKey = savedKey
verificationStatus = .success verificationStatus = .success
} }
@ -306,10 +303,10 @@ struct CloudModelCardView: View {
if isValid { if isValid {
self.verificationStatus = .success self.verificationStatus = .success
self.verificationError = nil self.verificationError = nil
// Save the API key // Save the API key to Keychain
UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey") APIKeyManager.shared.saveAPIKey(self.apiKey, forProvider: self.providerKey)
self.isConfiguredState = true self.isConfiguredState = true
// Collapse the configuration section after successful verification // Collapse the configuration section after successful verification
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
self.isExpanded = false self.isExpanded = false
@ -318,20 +315,17 @@ struct CloudModelCardView: View {
self.verificationStatus = .failure self.verificationStatus = .failure
self.verificationError = errorMessage self.verificationError = errorMessage
} }
// Restore original provider
// aiService.selectedProvider = originalProvider // This line was removed as per the new_code
} }
} }
} }
private func clearAPIKey() { private func clearAPIKey() {
UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey") APIKeyManager.shared.deleteAPIKey(forProvider: providerKey)
apiKey = "" apiKey = ""
verificationStatus = .none verificationStatus = .none
verificationError = nil verificationError = nil
isConfiguredState = false isConfiguredState = false
// If this model is currently the default, clear it // If this model is currently the default, clear it
if isCurrent { if isCurrent {
Task { Task {
@ -341,7 +335,7 @@ struct CloudModelCardView: View {
} }
} }
} }
withAnimation(.easeInOut(duration: 0.3)) { withAnimation(.easeInOut(duration: 0.3)) {
isExpanded = false isExpanded = false
} }

View File

@ -15,23 +15,17 @@ extension WhisperState {
return false return false
} }
case .groq: case .groq:
let key = UserDefaults.standard.string(forKey: "GROQAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "Groq")
return key != nil && !key!.isEmpty
case .elevenLabs: case .elevenLabs:
let key = UserDefaults.standard.string(forKey: "ElevenLabsAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "ElevenLabs")
return key != nil && !key!.isEmpty
case .deepgram: case .deepgram:
let key = UserDefaults.standard.string(forKey: "DeepgramAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "Deepgram")
return key != nil && !key!.isEmpty
case .mistral: case .mistral:
let key = UserDefaults.standard.string(forKey: "MistralAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "Mistral")
return key != nil && !key!.isEmpty
case .gemini: case .gemini:
let key = UserDefaults.standard.string(forKey: "GeminiAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "Gemini")
return key != nil && !key!.isEmpty
case .soniox: case .soniox:
let key = UserDefaults.standard.string(forKey: "SonioxAPIKey") return APIKeyManager.shared.hasAPIKey(forProvider: "Soniox")
return key != nil && !key!.isEmpty
case .custom: case .custom:
// Custom models are always usable since they contain their own API keys // Custom models are always usable since they contain their own API keys
return true return true