From aab096d252064195ab7c073e55f4e3b51afcdb10 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Mon, 5 Jan 2026 23:06:46 +0545 Subject: [PATCH] Move license key and trial date from UserDefaults to Keychain --- VoiceInk/Models/LicenseViewModel.swift | 77 ++++++------ VoiceInk/Services/LicenseManager.swift | 125 ++++++++++++++++++++ VoiceInk/Services/SystemInfoService.swift | 6 +- VoiceInk/Services/UserDefaultsManager.swift | 43 +------ 4 files changed, 165 insertions(+), 86 deletions(-) create mode 100644 VoiceInk/Services/LicenseManager.swift 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/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 cadeee6..29aad14 100644 --- a/VoiceInk/Services/UserDefaultsManager.swift +++ b/VoiceInk/Services/UserDefaultsManager.swift @@ -2,51 +2,10 @@ import Foundation extension UserDefaults { enum Keys { - 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: - 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 @@ -72,4 +31,4 @@ extension UserDefaults { get { bool(forKey: Keys.affiliatePromotionDismissed) } set { setValue(newValue, forKey: Keys.affiliatePromotionDismissed) } } -} \ No newline at end of file +}