diff --git a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj index 71346fa4..5da77387 100644 --- a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj +++ b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 9DD6CAAF2F3C426600ED7098 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CAAD2F3C426600ED7098 /* Secrets.swift */; }; 9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */; }; 9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894B02F4047630090B9B9 /* SettingsView.swift */; }; + 9DD894B52F4047630090B9B9 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894B42F4047630090B9B9 /* KeychainManager.swift */; }; 9DD894C42F4047630090B9C4 /* ConnectedAppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */; }; 9DD894C22F4047630090B9C2 /* RecentTasksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD894C12F4047630090B9C1 /* RecentTasksView.swift */; }; A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* GeminiConfig.swift */; }; @@ -91,6 +92,7 @@ 9DD6CAAE2F3C426600ED7098 /* Secrets.swift.example */ = {isa = PBXFileReference; lastKnownFileType = text; path = Secrets.swift.example; sourceTree = ""; }; 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; 9DD894B02F4047630090B9B9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 9DD894B42F4047630090B9B9 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; 9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectedAppsView.swift; sourceTree = ""; }; 9DD894C12F4047630090B9C1 /* RecentTasksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentTasksView.swift; sourceTree = ""; }; A1B2C3D42F0A000100000001 /* GeminiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiConfig.swift; sourceTree = ""; }; @@ -222,6 +224,7 @@ isa = PBXGroup; children = ( 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */, + 9DD894B42F4047630090B9B9 /* KeychainManager.swift */, 9DD894B02F4047630090B9B9 /* SettingsView.swift */, 9DD894C32F4047630090B9C3 /* ConnectedAppsView.swift */, 9DD894C12F4047630090B9C1 /* RecentTasksView.swift */, @@ -384,6 +387,7 @@ E6FD3BCE2EB4D53A00E7FE5D /* NonStreamView.swift in Sources */, A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */, 9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */, + 9DD894B52F4047630090B9B9 /* KeychainManager.swift in Sources */, 9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */, 9DD894C42F4047630090B9C4 /* ConnectedAppsView.swift in Sources */, 9DD894C22F4047630090B9C2 /* RecentTasksView.swift in Sources */, diff --git a/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift b/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift new file mode 100644 index 00000000..65030774 --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift @@ -0,0 +1,62 @@ +import Foundation +import Security + +/// Thin wrapper around the iOS Keychain for storing sensitive strings (API keys, tokens). +/// Replaces UserDefaults for secret storage — UserDefaults is unencrypted and readable +/// from device backups. +enum KeychainManager { + private static let service = "com.visionclaw.secrets" + + static func get(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + @discardableResult + static func set(_ key: String, value: String) -> Bool { + let data = Data(value.utf8) + + // Try update first (cheaper than delete+add) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + let update: [String: Any] = [kSecValueData as String: data] + let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary) + + if updateStatus == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = data + return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess + } + + return updateStatus == errSecSuccess + } + + static func delete(_ key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } + + static func deleteAll() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift index cd0fa293..9761e0d2 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift @@ -50,15 +50,18 @@ final class SettingsManager { private let defaults = UserDefaults.standard - private enum Key: String { + private enum SecretKey: String, CaseIterable { case geminiAPIKey + case openClawHookToken + case openClawGatewayToken + case cloudGatewayToken + } + + private enum Key: String { case agentBackend case openClawHost case openClawPort - case openClawHookToken - case openClawGatewayToken case cloudGatewayURL - case cloudGatewayToken case accountEmail case accountStatus case geminiSystemPrompt @@ -67,13 +70,30 @@ final class SettingsManager { case proactiveNotificationsEnabled } - private init() {} + private init() { + migrateSecretsFromUserDefaults() + } + + private func migrateSecretsFromUserDefaults() { + let migrationKey = "secrets_migrated_to_keychain" + guard !defaults.bool(forKey: migrationKey) else { return } + + for key in SecretKey.allCases { + if let value = defaults.string(forKey: key.rawValue), !value.isEmpty { + if KeychainManager.set(key.rawValue, value: value) { + defaults.removeObject(forKey: key.rawValue) + } + } + } + + defaults.set(true, forKey: migrationKey) + } // MARK: - Gemini var geminiAPIKey: String { - get { defaults.string(forKey: Key.geminiAPIKey.rawValue) ?? Secrets.geminiAPIKey } - set { defaults.set(newValue, forKey: Key.geminiAPIKey.rawValue) } + get { KeychainManager.get(SecretKey.geminiAPIKey.rawValue) ?? Secrets.geminiAPIKey } + set { KeychainManager.set(SecretKey.geminiAPIKey.rawValue, value: newValue) } } var geminiSystemPrompt: String { @@ -97,13 +117,13 @@ final class SettingsManager { } var openClawHookToken: String { - get { defaults.string(forKey: Key.openClawHookToken.rawValue) ?? Secrets.openClawHookToken } - set { defaults.set(newValue, forKey: Key.openClawHookToken.rawValue) } + get { KeychainManager.get(SecretKey.openClawHookToken.rawValue) ?? Secrets.openClawHookToken } + set { KeychainManager.set(SecretKey.openClawHookToken.rawValue, value: newValue) } } var openClawGatewayToken: String { - get { defaults.string(forKey: Key.openClawGatewayToken.rawValue) ?? Secrets.openClawGatewayToken } - set { defaults.set(newValue, forKey: Key.openClawGatewayToken.rawValue) } + get { KeychainManager.get(SecretKey.openClawGatewayToken.rawValue) ?? Secrets.openClawGatewayToken } + set { KeychainManager.set(SecretKey.openClawGatewayToken.rawValue, value: newValue) } } // MARK: - Agent backend selection @@ -151,8 +171,8 @@ final class SettingsManager { } var cloudGatewayToken: String { - get { defaults.string(forKey: Key.cloudGatewayToken.rawValue) ?? Secrets.cloudGatewayToken } - set { defaults.set(newValue, forKey: Key.cloudGatewayToken.rawValue) } + get { KeychainManager.get(SecretKey.cloudGatewayToken.rawValue) ?? Secrets.cloudGatewayToken } + set { KeychainManager.set(SecretKey.cloudGatewayToken.rawValue, value: newValue) } } // MARK: - Account (Google sign-in) @@ -193,8 +213,9 @@ final class SettingsManager { // MARK: - Reset func resetAll() { - for key in [Key.geminiAPIKey, .geminiSystemPrompt, .agentBackend, .openClawHost, .openClawPort, - .openClawHookToken, .openClawGatewayToken, .cloudGatewayURL, .cloudGatewayToken, + KeychainManager.deleteAll() + for key in [Key.geminiSystemPrompt, .agentBackend, .openClawHost, .openClawPort, + .cloudGatewayURL, .accountEmail, .accountStatus, .speakerOutputEnabled, .videoStreamingEnabled, .proactiveNotificationsEnabled] {