From d3c5f8f6147e5a9a5b2c00e3a004f064e5c301ab Mon Sep 17 00:00:00 2001 From: julscezar Date: Mon, 6 Apr 2026 05:06:31 -0600 Subject: [PATCH 1/2] security: migrate secrets from UserDefaults to iOS Keychain (fixes #1) API keys and tokens were stored in UserDefaults, which is unencrypted and readable from device backups. This is a security risk flagged in Issue #1. Changes: - New KeychainManager.swift: thin wrapper around Security.framework for storing/retrieving sensitive strings in the iOS Keychain - SettingsManager now stores geminiAPIKey, openClawHookToken, and openClawGatewayToken in Keychain instead of UserDefaults - One-time automatic migration: existing UserDefaults secrets are moved to Keychain on first launch, then deleted from UserDefaults - Non-sensitive settings (host, port, toggles) remain in UserDefaults Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Settings/KeychainManager.swift | 59 +++++++++++++++++++ .../Settings/SettingsManager.swift | 52 +++++++++++----- 2 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift diff --git a/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift b/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift new file mode 100644 index 00000000..16f15e36 --- /dev/null +++ b/samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift @@ -0,0 +1,59 @@ +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) + } + + static func set(_ key: String, value: String) { + 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 + SecItemAdd(addQuery as CFDictionary, nil) + } + } + + 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 8d63a557..c6633fbc 100644 --- a/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift +++ b/samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift @@ -5,12 +5,17 @@ final class SettingsManager { private let defaults = UserDefaults.standard - private enum Key: String { + // Keys for secrets (stored in Keychain) + private enum SecretKey: String { case geminiAPIKey - case openClawHost - case openClawPort case openClawHookToken case openClawGatewayToken + } + + // Keys for non-sensitive settings (stored in UserDefaults) + private enum Key: String { + case openClawHost + case openClawPort case geminiSystemPrompt case webrtcSignalingURL case speakerOutputEnabled @@ -18,13 +23,32 @@ final class SettingsManager { case proactiveNotificationsEnabled } - private init() {} + private init() { + migrateSecretsFromUserDefaults() + } + + /// One-time migration: move secrets from UserDefaults to Keychain. + /// Runs on first launch after update — old UserDefaults entries are deleted. + private func migrateSecretsFromUserDefaults() { + let migrationKey = "secrets_migrated_to_keychain" + guard !defaults.bool(forKey: migrationKey) else { return } + + for key in [SecretKey.geminiAPIKey, .openClawHookToken, .openClawGatewayToken] { + if let value = defaults.string(forKey: key.rawValue), !value.isEmpty { + KeychainManager.set(key.rawValue, value: value) + defaults.removeObject(forKey: key.rawValue) + NSLog("[Settings] Migrated %@ from UserDefaults to Keychain", key.rawValue) + } + } + + defaults.set(true, forKey: migrationKey) + } - // MARK: - Gemini + // MARK: - Gemini (secrets in Keychain) 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 { @@ -48,13 +72,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: - WebRTC @@ -88,9 +112,9 @@ final class SettingsManager { // MARK: - Reset func resetAll() { - for key in [Key.geminiAPIKey, .geminiSystemPrompt, .openClawHost, .openClawPort, - .openClawHookToken, .openClawGatewayToken, .webrtcSignalingURL, - .speakerOutputEnabled, .videoStreamingEnabled, + KeychainManager.deleteAll() + for key in [Key.geminiSystemPrompt, .openClawHost, .openClawPort, + .webrtcSignalingURL, .speakerOutputEnabled, .videoStreamingEnabled, .proactiveNotificationsEnabled] { defaults.removeObject(forKey: key.rawValue) } From 140dffa5dbe20ab56dc03e3fba708212cf4f9490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julio=20C=C3=A9sar=20Su=C3=A1stegui?= Date: Thu, 25 Jun 2026 01:20:53 -0600 Subject: [PATCH 2/2] fix: include keychain manager in camera target --- samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj index 1e7dbda4..84a4a53a 100644 --- a/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj +++ b/samples/CameraAccess/CameraAccess.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 9DD6CB0E2F3C64F400ED7098 /* WebRTCOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD6CB0D2F3C64F400ED7098 /* WebRTCOverlayView.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 */; }; 9DD895962F405E0E0090B9B9 /* RTCVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD895952F405E0E0090B9B9 /* RTCVideoView.swift */; }; 9DD895972F405E0E0090B9B9 /* PiPVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DD895942F405E0E0090B9B9 /* PiPVideoView.swift */; }; A1B2C3D42F0A000200000001 /* GeminiConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* GeminiConfig.swift */; }; @@ -108,6 +109,7 @@ 9DD6CB0D2F3C64F400ED7098 /* WebRTCOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCOverlayView.swift; 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 = ""; }; 9DD895942F405E0E0090B9B9 /* PiPVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPVideoView.swift; sourceTree = ""; }; 9DD895952F405E0E0090B9B9 /* RTCVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoView.swift; sourceTree = ""; }; A1B2C3D42F0A000100000001 /* GeminiConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiConfig.swift; sourceTree = ""; }; @@ -261,6 +263,7 @@ isa = PBXGroup; children = ( 9DD894AF2F4047630090B9B9 /* SettingsManager.swift */, + 9DD894B42F4047630090B9B9 /* KeychainManager.swift */, 9DD894B02F4047630090B9B9 /* SettingsView.swift */, ); path = Settings; @@ -435,6 +438,7 @@ A1B2C3D42F0A000200000003 /* AudioManager.swift in Sources */, A1B2C3D42F0A000200000004 /* GeminiSessionViewModel.swift in Sources */, 9DD894B22F4047630090B9B9 /* SettingsManager.swift in Sources */, + 9DD894B52F4047630090B9B9 /* KeychainManager.swift in Sources */, 9DD894B32F4047630090B9B9 /* SettingsView.swift in Sources */, A1B2C3D42F0A000200000005 /* GeminiOverlayView.swift in Sources */, );