Repository navigation
Add: Store API keys in the Keychain #66
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0050b13
Add: Store API keys in the Keychain
raphaelbarreiros 899fafa
Fix: Keychain secrets after a reset or a failed write
raphaelbarreiros 478f2fc
Fix: Reload unreadable Keychain secrets on unlock; let keys migrate
raphaelbarreiros 2066373
Merge remote-tracking branch 'upstream/main' into feat/keychain-secrets
raphaelbarreiros 2eb5e83
Fix: Keep a cleared key cleared when its Keychain delete fails
raphaelbarreiros a51bc88
Merge main into feat/keychain-secrets (resolve pbxproj: keep both tes…
rayl15 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| // OpenVision - SettingsSecrets.swift | ||
| // API keys and tokens from AppSettings, kept in the Keychain instead of settings.json. | ||
| // | ||
| // AppSettings still holds them in memory, so the views and services read and write them as before. | ||
| // Only persistence changes: SettingsManager writes them here and saves settings.json with those | ||
| // fields blank. A settings.json from an older build still has them in plain text; loading it | ||
| // moves them here and the next save blanks them in the file. | ||
|
|
||
| import Foundation | ||
| import Security | ||
|
|
||
| enum SettingsSecrets { | ||
|
|
||
| /// Every secret field, by its Keychain account. | ||
| static let fields: [(account: String, keyPath: WritableKeyPath<AppSettings, String>)] = [ | ||
| ("openClawAuthToken", \.openClawAuthToken), | ||
| ("geminiAPIKey", \.geminiAPIKey), | ||
| ("openAIAPIKey", \.openAIAPIKey), | ||
| ("grokAPIKey", \.grokAPIKey), | ||
| ("hermesAPIKey", \.hermesAPIKey), | ||
| ("tavilyAPIKey", \.tavilyAPIKey), | ||
| ("telemetryToken", \.telemetryToken), | ||
| ("telemetryPassword", \.telemetryPassword), | ||
| ] | ||
|
|
||
| /// What the Keychain said about one account: a value, nothing stored, or no answer (it can't | ||
| /// be read before the phone's first unlock). | ||
| enum Stored: Equatable { | ||
| case value(String) | ||
| case none | ||
| case unreadable | ||
| } | ||
|
|
||
| /// The settings to use after loading: a value still in the file (an older build's) wins, since | ||
| /// it's what the user last set; otherwise the Keychain's. Also returns the accounts that | ||
| /// couldn't be read, which mustn't be overwritten or deleted until they can. | ||
| static func merge(file: AppSettings, keychain: [String: Stored]) -> (settings: AppSettings, unreadable: Set<String>) { | ||
| var settings = file | ||
| var unreadable: Set<String> = [] | ||
| for (account, keyPath) in fields where file[keyPath: keyPath].isEmpty { | ||
| switch keychain[account] ?? .none { | ||
| case .value(let value): settings[keyPath: keyPath] = value | ||
| case .none: break | ||
| case .unreadable: unreadable.insert(account) | ||
| } | ||
| } | ||
| return (settings, unreadable) | ||
| } | ||
|
|
||
| /// The settings as written to settings.json: secrets blank, except any whose Keychain write | ||
| /// failed, which stay in the file rather than be lost. | ||
| static func forFile(_ settings: AppSettings, keepInFile: Set<String>) -> AppSettings { | ||
| var stripped = settings | ||
| for (account, keyPath) in fields where !keepInFile.contains(account) { | ||
| stripped[keyPath: keyPath] = "" | ||
| } | ||
| return stripped | ||
| } | ||
|
|
||
| // MARK: - Keychain | ||
|
|
||
| private static let service = (Bundle.main.bundleIdentifier ?? "openvision") + ".settings" | ||
|
|
||
| private static func baseQuery(_ account: String) -> [String: Any] { | ||
| [kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account] | ||
| } | ||
|
|
||
| static func read(_ account: String) -> Stored { | ||
| var query = baseQuery(account) | ||
| query[kSecReturnData as String] = true | ||
| query[kSecMatchLimit as String] = kSecMatchLimitOne | ||
| var result: AnyObject? | ||
| let status = SecItemCopyMatching(query as CFDictionary, &result) | ||
| switch status { | ||
| case errSecSuccess: | ||
| guard let data = result as? Data, let value = String(data: data, encoding: .utf8) else { return .none } | ||
| return value.isEmpty ? .none : .value(value) | ||
| case errSecItemNotFound: | ||
| return .none | ||
| default: | ||
| NSLog("[Secrets] keychain read for %@ failed: %d", account, status) | ||
| return .unreadable | ||
| } | ||
| } | ||
|
|
||
| /// Store `value`, or delete the item when it's empty. Returns whether the Keychain took it. | ||
| @discardableResult | ||
| static func write(_ value: String, account: String) -> Bool { | ||
| if value.isEmpty { | ||
| let status = SecItemDelete(baseQuery(account) as CFDictionary) | ||
| return status == errSecSuccess || status == errSecItemNotFound | ||
| } | ||
| // Accessible after first unlock, so replies still work in a pocket. Not "this device only" | ||
| // (unlike the sign-in tokens): pasted API keys travel with an encrypted backup or Quick | ||
| // Start to a new phone, as they did when they lived in settings.json. | ||
| let attributes: [String: Any] = [ | ||
| kSecValueData as String: Data(value.utf8), | ||
| kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, | ||
| ] | ||
| var status = SecItemUpdate(baseQuery(account) as CFDictionary, attributes as CFDictionary) | ||
| if status == errSecItemNotFound { | ||
| status = SecItemAdd(baseQuery(account).merging(attributes) { $1 } as CFDictionary, nil) | ||
| } | ||
| if status != errSecSuccess { NSLog("[Secrets] keychain save failed for %@: %d", account, status) } | ||
| return status == errSecSuccess | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.