3c11ce2903
Security: API key moves from App Group UserDefaults (plaintext on disk)
to the iOS Keychain. The host app writes in Settings; the keyboard
extension reads before each request. Cross-process sharing is via a new
shared keychain-access-group declared in both targets' entitlements.
UX: when the user denies microphone or speech-recognition permission,
the message becomes a tappable row that opens the host app's settings.
Previously the message said "请到「设置」中允许" but the only way to
actually get there was a top-bar ⚙ button that wasn't obviously
related. Auto-clear (2.4s) is now suppressed for .denied so the user
has time to read it. Re-pressing the mic from .denied re-checks
permission so the user can simply press again after granting.
API Keychain migration
----------------------
- New `OSGKeyboardShared/Services/Keychain.swift` — minimal
`kSecClassGenericPassword` wrapper for one item
(service "com.osgkeyboard.apikey", account "current"), backed by
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (no iCloud sync).
`setAPIKey("")` deletes the entry rather than storing an empty
placeholder so "stored but empty" stays distinguishable from
"not stored" for the noAPIKey error path.
- `ProviderConfig.apiKey` now reads/writes through Keychain instead
of UserDefaults. `didSet` skips the round-trip when oldValue equals
apiKey (init reads Keychain, then assigns — without this guard the
init write would silently re-write the same value).
- One-shot migration: on first `ProviderConfig.init` after upgrade,
a legacy `config.apiKey` UserDefaults entry is copied to Keychain
and removed from UserDefaults. The legacy key is renamed in code to
`apiKeyLegacy` so future reads of `config.apiKey` from UserDefaults
would be a bug.
- `AppGroupStore.apiKey` reads from Keychain (was UserDefaults).
- Cross-process sharing: both targets' entitlements gain
`com.apple.security.keychain-access-groups: ["com.osgkeyboard.shared"]`.
`com.osgkeyboard.shared` is the first entry in both, so it becomes
each process's default access group — Keychain queries don't need to
specify `kSecAttrAccessGroup`.
Permission-denied UX
--------------------
- `KeyboardViewController.pressBegan` now accepts `.denied` and
`.error` as starting states (previously only `.idle`), so pressing
the mic after returning from Settings re-checks permission
without waiting for an auto-clear.
- `scheduleAutoClearError` no longer clears `.denied` — only
transient `.error` is timed. `.denied` is sticky until the user
takes action (taps the row → settings, or presses mic → re-check).
- `TranscriptLine` `.denied` case now wraps the text in a Button
that calls `state.openSettings`, with a `chevron.right` to make
the affordance obvious. The text was shortened to
"麦克风被拒绝" / "语音识别被拒绝" so the chevron has room and
the action isn't implied twice (it was previously both in the
text and via the top-bar ⚙ button).
- VoiceOver hint on the button: "Opens the OSGKeyboard settings
page where you can grant microphone or speech recognition access."
Tests
-----
- New `OSGKeyboardTests/KeychainTests.swift` — 6 tests covering
round-trip, empty-string-deletes, idempotent-delete,
AppGroupStore-reads-from-Keychain, legacy UserDefaults → Keychain
migration, and "Keychain wins when both are present".
- `LLMClientTests` setUp/tearDown now wipes the Keychain
(`try? Keychain.deleteAPIKey()`) and clears `StubURLProtocolStorage`
so tests are independent across runs in the same simulator process.
- All 21 tests pass (6 new + 15 existing).
🤖 Generated with Claude Code
131 lines
5.0 KiB
Swift
131 lines
5.0 KiB
Swift
// Keychain.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Single-purpose Keychain helper for the user's LLM API key.
|
|
//
|
|
// Why this exists
|
|
// ---------------
|
|
// Both the host app and the keyboard extension need to read the same API
|
|
// key (the host writes it in Settings; the extension uses it to
|
|
// authenticate LLM requests). Storing it in App Group `UserDefaults` is
|
|
// plaintext on disk and shows up in any unencrypted backup. The Keychain
|
|
// gives us at-rest encryption and proper lifecycle.
|
|
//
|
|
// Cross-process sharing
|
|
// ---------------------
|
|
// App and extension have different bundle IDs, so their default Keychain
|
|
// access groups differ and they cannot see each other's items out of the
|
|
// box. We add `com.apple.security.keychain-access-groups` to both
|
|
// targets' entitlements with the entry `com.osgkeyboard.shared`; this
|
|
// becomes each process's *first* (and therefore default) access group, so
|
|
// we never need to specify `kSecAttrAccessGroup` in queries — the system
|
|
// resolves it for us.
|
|
//
|
|
// Accessibility class
|
|
// -------------------
|
|
// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`:
|
|
// - Available after the user unlocks the device at least once after
|
|
// boot (so background jobs work even with a locked phone).
|
|
// - "ThisDeviceOnly" — does not migrate to a restored device and is
|
|
// NOT included in iCloud Keychain. API keys should not sync.
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
public enum Keychain: @unchecked Sendable {
|
|
|
|
public enum KeychainError: Error, Sendable, Equatable {
|
|
case unexpectedStatus(OSStatus)
|
|
}
|
|
|
|
private static let service = "com.osgkeyboard.apikey"
|
|
private static let account = "current"
|
|
|
|
// MARK: - Read
|
|
|
|
/// Read the stored API key. Returns `nil` when nothing is stored,
|
|
/// or when the underlying call returns a non-success status we can't
|
|
/// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
|
|
public static func apiKey() -> String? {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
var result: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
switch status {
|
|
case errSecSuccess:
|
|
guard let data = result as? Data,
|
|
let str = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return str
|
|
case errSecItemNotFound:
|
|
return nil
|
|
default:
|
|
#if DEBUG
|
|
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.")
|
|
#endif
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Write
|
|
|
|
/// Store (or update) the API key. An empty string deletes the entry,
|
|
/// so clearing the field in the UI removes the key from the Keychain
|
|
/// rather than leaving an empty-string placeholder.
|
|
public static func setAPIKey(_ key: String) throws {
|
|
if key.isEmpty {
|
|
try deleteAPIKey()
|
|
return
|
|
}
|
|
let data = Data(key.utf8)
|
|
let baseQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
// Try update first — covers the common path where the key already
|
|
// exists (every settings edit after the first).
|
|
let updateAttrs: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
]
|
|
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
|
|
switch updateStatus {
|
|
case errSecSuccess:
|
|
return
|
|
case errSecItemNotFound:
|
|
// No existing item — add one with our accessibility class.
|
|
var addQuery = baseQuery
|
|
addQuery[kSecValueData as String] = data
|
|
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
|
if addStatus != errSecSuccess {
|
|
throw KeychainError.unexpectedStatus(addStatus)
|
|
}
|
|
default:
|
|
throw KeychainError.unexpectedStatus(updateStatus)
|
|
}
|
|
}
|
|
|
|
// MARK: - Delete
|
|
|
|
public static func deleteAPIKey() throws {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
let status = SecItemDelete(query as CFDictionary)
|
|
// `errSecItemNotFound` is success-from-the-user's-perspective — the
|
|
// desired end state is "no key", which is what we already have.
|
|
if status != errSecSuccess && status != errSecItemNotFound {
|
|
throw KeychainError.unexpectedStatus(status)
|
|
}
|
|
}
|
|
}
|