Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions samples/CameraAccess/CameraAccess/Settings/KeychainManager.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
52 changes: 38 additions & 14 deletions samples/CameraAccess/CameraAccess/Settings/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,50 @@ 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
case videoStreamingEnabled
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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down