feat: API key in Keychain + actionable permission-denied UX
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
This commit is contained in:
@@ -8,5 +8,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>com.osgkeyboard.shared</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -125,7 +125,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// MARK: - Press handlers
|
// MARK: - Press handlers
|
||||||
|
|
||||||
private func pressBegan() {
|
private func pressBegan() {
|
||||||
guard state.phase == .idle else { return }
|
// Allow re-entry from `.denied` and from a finished/cleared
|
||||||
|
// `.error` so the user can simply press the mic again after
|
||||||
|
// returning from Settings with permission granted — they
|
||||||
|
// shouldn't have to wait for an auto-clear timer.
|
||||||
|
switch state.phase {
|
||||||
|
case .idle, .denied, .error:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
guard state.mode != .off else { return }
|
guard state.mode != .off else { return }
|
||||||
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
|
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
|
||||||
// press (before the first Task has had a chance to flip phase to
|
// press (before the first Task has had a chance to flip phase to
|
||||||
@@ -138,7 +147,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
let micGranted = await self.permissions.requestMicPermission()
|
let micGranted = await self.permissions.requestMicPermission()
|
||||||
guard micGranted else {
|
guard micGranted else {
|
||||||
self.state.phase = .denied(.mic)
|
self.state.phase = .denied(.mic)
|
||||||
self.scheduleAutoClearError()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech
|
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech
|
||||||
@@ -152,7 +160,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
let speechGranted = await self.permissions.requestSpeechPermission()
|
let speechGranted = await self.permissions.requestSpeechPermission()
|
||||||
guard speechGranted else {
|
guard speechGranted else {
|
||||||
self.state.phase = .denied(.speech)
|
self.state.phase = .denied(.speech)
|
||||||
self.scheduleAutoClearError()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.startPipeline()
|
self.startPipeline()
|
||||||
@@ -357,11 +364,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// Clear both .error (transient error message) and .denied
|
// Only transient errors auto-clear. `.denied` is sticky: the
|
||||||
// (permission was rejected — show the message, then return
|
// user needs the message long enough to read it AND decide
|
||||||
// to idle so the user can navigate away).
|
// whether to tap "去设置" or tap the mic to retry. They
|
||||||
|
// dismiss it implicitly by doing either of those things.
|
||||||
switch self.state.phase {
|
switch self.state.phase {
|
||||||
case .error, .denied:
|
case .error:
|
||||||
self.state.phase = .idle
|
self.state.phase = .idle
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -6,5 +6,9 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>group.com.osgkeyboard.shared</string>
|
<string>group.com.osgkeyboard.shared</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>com.apple.security.keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>com.osgkeyboard.shared</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ public struct KeyboardRootView: View {
|
|||||||
private var centreArea: some View {
|
private var centreArea: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack(spacing: Spacing.xxs) {
|
VStack(spacing: Spacing.xxs) {
|
||||||
TranscriptLine(phase: state.phase, transcript: state.lastTranscript)
|
TranscriptLine(
|
||||||
|
phase: state.phase,
|
||||||
|
transcript: state.lastTranscript,
|
||||||
|
openSettings: state.openSettings
|
||||||
|
)
|
||||||
.frame(height: 22)
|
.frame(height: 22)
|
||||||
RecordButton(
|
RecordButton(
|
||||||
phase: buttonPhase,
|
phase: buttonPhase,
|
||||||
@@ -200,6 +204,7 @@ private struct TranscriptLine: View {
|
|||||||
|
|
||||||
let phase: KeyboardViewController.State.Phase
|
let phase: KeyboardViewController.State.Phase
|
||||||
let transcript: String
|
let transcript: String
|
||||||
|
let openSettings: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -236,11 +241,21 @@ private struct TranscriptLine: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
case .denied(let reason):
|
case .denied(let reason):
|
||||||
|
Button(action: openSettings) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
Text(deniedMessage(for: reason))
|
Text(deniedMessage(for: reason))
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.warning)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
}
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.warning)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(Text("Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -249,8 +264,8 @@ private struct TranscriptLine: View {
|
|||||||
|
|
||||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||||
switch reason {
|
switch reason {
|
||||||
case .mic: return "麦克风被拒绝 · 请到「设置」中允许"
|
case .mic: return "麦克风被拒绝"
|
||||||
case .speech: return "语音识别被拒绝 · 请到「设置」中允许"
|
case .speech: return "语音识别被拒绝"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
//
|
//
|
||||||
// User's LLM configuration. Persisted in App Group UserDefaults so both
|
// User's LLM configuration. Persisted in App Group UserDefaults so both
|
||||||
// the main app and keyboard extension read the same values.
|
// the main app and keyboard extension read the same values.
|
||||||
|
//
|
||||||
|
// `apiKey` is the exception: it lives in the Keychain (see
|
||||||
|
// `Keychain.swift`) for at-rest encryption. The first time this struct
|
||||||
|
// inits after upgrade, a legacy plaintext value from UserDefaults is
|
||||||
|
// migrated to the Keychain and removed from UserDefaults.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
@@ -13,7 +18,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
static let providerId = "config.providerId"
|
static let providerId = "config.providerId"
|
||||||
static let baseURL = "config.baseURL"
|
static let baseURL = "config.baseURL"
|
||||||
static let apiKey = "config.apiKey"
|
// Legacy: apiKey used to live in UserDefaults before the
|
||||||
|
// migration. We still read it once (see init below) and then
|
||||||
|
// delete the entry, but no other code path touches this key.
|
||||||
|
static let apiKeyLegacy = "config.apiKey"
|
||||||
static let model = "config.model"
|
static let model = "config.model"
|
||||||
static let systemPrompt = "config.systemPrompt"
|
static let systemPrompt = "config.systemPrompt"
|
||||||
static let modeId = "config.modeId"
|
static let modeId = "config.modeId"
|
||||||
@@ -27,7 +35,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||||
}
|
}
|
||||||
@Published public var apiKey: String {
|
@Published public var apiKey: String {
|
||||||
didSet { defaults.set(apiKey, forKey: Key.apiKey) }
|
didSet {
|
||||||
|
// Skip the round-trip on init — we read from Keychain and
|
||||||
|
// writing the same value back is wasteful.
|
||||||
|
guard oldValue != apiKey else { return }
|
||||||
|
do {
|
||||||
|
try Keychain.setAPIKey(apiKey)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@Published public var model: String {
|
@Published public var model: String {
|
||||||
didSet { defaults.set(model, forKey: Key.model) }
|
didSet { defaults.set(model, forKey: Key.model) }
|
||||||
@@ -60,7 +79,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
let preset = LLMProvider.provider(id: pid)
|
let preset = LLMProvider.provider(id: pid)
|
||||||
self.providerId = pid
|
self.providerId = pid
|
||||||
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||||
self.apiKey = defaults.string(forKey: Key.apiKey) ?? ""
|
|
||||||
|
// Resolve the API key with a one-shot migration from the legacy
|
||||||
|
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||||
|
// is empty in the suite and all subsequent reads go through the
|
||||||
|
// Keychain.
|
||||||
|
self.apiKey = ProviderConfig.resolveAPIKey(defaults: defaults)
|
||||||
|
|
||||||
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
|
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||||
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
|
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
|
||||||
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
||||||
@@ -68,6 +93,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the API key from the Keychain, falling back to a one-time
|
||||||
|
/// migration from the legacy UserDefaults slot.
|
||||||
|
private static func resolveAPIKey(defaults: UserDefaults) -> String {
|
||||||
|
if let stored = Keychain.apiKey(), !stored.isEmpty {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
||||||
|
!legacy.isEmpty {
|
||||||
|
try? Keychain.setAPIKey(legacy)
|
||||||
|
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
public func apply(preset: LLMProvider) {
|
public func apply(preset: LLMProvider) {
|
||||||
// Capture the *previous* provider id BEFORE we mutate, so the
|
// Capture the *previous* provider id BEFORE we mutate, so the
|
||||||
// system-prompt reset check below can compare against the actual
|
// system-prompt reset check below can compare against the actual
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||||
// instantiating an ObservableObject.
|
// instantiating an ObservableObject.
|
||||||
|
//
|
||||||
|
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
|
||||||
|
// share access between the host app and the keyboard extension via a
|
||||||
|
// shared keychain-access-group declared in both targets' entitlements.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -19,7 +23,6 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
private enum Key {
|
private enum Key {
|
||||||
static let providerId = "config.providerId"
|
static let providerId = "config.providerId"
|
||||||
static let baseURL = "config.baseURL"
|
static let baseURL = "config.baseURL"
|
||||||
static let apiKey = "config.apiKey"
|
|
||||||
static let model = "config.model"
|
static let model = "config.model"
|
||||||
static let systemPrompt = "config.systemPrompt"
|
static let systemPrompt = "config.systemPrompt"
|
||||||
static let modeId = "config.modeId"
|
static let modeId = "config.modeId"
|
||||||
@@ -36,8 +39,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||||
|
/// Returns "" when nothing is stored so the LLMClient can surface a
|
||||||
|
/// `noAPIKey` error rather than firing off an obviously-bad request.
|
||||||
public var apiKey: String {
|
public var apiKey: String {
|
||||||
defaults.string(forKey: Key.apiKey) ?? ""
|
Keychain.apiKey() ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
public var model: String {
|
public var model: String {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// KeychainTests.swift
|
||||||
|
// OSGKeyboard · Tests
|
||||||
|
//
|
||||||
|
// Unit tests for the Keychain helper and the one-time migration from
|
||||||
|
// the legacy UserDefaults slot. The Keychain is process-global in the
|
||||||
|
// simulator, so every test cleans up after itself.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class KeychainTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
try? Keychain.deleteAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? Keychain.deleteAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Round-trip
|
||||||
|
|
||||||
|
func testRoundTripWriteReadDelete() throws {
|
||||||
|
// Nothing stored yet.
|
||||||
|
XCTAssertNil(Keychain.apiKey(), "Keychain should start empty after cleanup")
|
||||||
|
|
||||||
|
// Write → read.
|
||||||
|
try Keychain.setAPIKey("sk-roundtrip-1")
|
||||||
|
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-1")
|
||||||
|
|
||||||
|
// Overwrite → read new value (no orphan entries).
|
||||||
|
try Keychain.setAPIKey("sk-roundtrip-2")
|
||||||
|
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-2")
|
||||||
|
|
||||||
|
// Delete → read nil.
|
||||||
|
try Keychain.deleteAPIKey()
|
||||||
|
XCTAssertNil(Keychain.apiKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty string must DELETE the entry, not store an empty placeholder.
|
||||||
|
/// Otherwise `Keychain.apiKey() ?? ""` would always return "" for any
|
||||||
|
/// missing item, and the LLM client couldn't tell "stored but empty"
|
||||||
|
/// (user error) from "not stored" (onboarding state).
|
||||||
|
func testEmptyStringDeletes() throws {
|
||||||
|
try Keychain.setAPIKey("sk-temp")
|
||||||
|
XCTAssertEqual(Keychain.apiKey(), "sk-temp")
|
||||||
|
|
||||||
|
try Keychain.setAPIKey("")
|
||||||
|
XCTAssertNil(Keychain.apiKey(), "Empty write must delete, not store empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleting a non-existent entry must be a no-op (idempotent), not an
|
||||||
|
/// error — callers like `ProviderConfig.reset()` invoke it
|
||||||
|
/// unconditionally.
|
||||||
|
func testDeleteIsIdempotent() throws {
|
||||||
|
// No prior write — should not throw.
|
||||||
|
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
||||||
|
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AppGroupStore reading
|
||||||
|
|
||||||
|
/// `AppGroupStore.apiKey` must consult the Keychain (not UserDefaults),
|
||||||
|
/// otherwise the keyboard extension never sees the key set in the
|
||||||
|
/// host app's Settings UI.
|
||||||
|
func testAppGroupStoreReadsFromKeychain() throws {
|
||||||
|
let suiteName = "group.com.osgkeyboard.shared.tests.kc.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
|
try Keychain.setAPIKey("sk-from-store")
|
||||||
|
let store = AppGroupStore(defaults: defaults)
|
||||||
|
XCTAssertEqual(store.apiKey, "sk-from-store")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Legacy migration
|
||||||
|
|
||||||
|
/// Pre-Keychain versions of the app stored the API key in
|
||||||
|
/// `config.apiKey` (UserDefaults). On first init after upgrade, that
|
||||||
|
/// value must be moved to the Keychain and removed from UserDefaults
|
||||||
|
/// — otherwise a fresh install in a clean simulator would inherit the
|
||||||
|
/// stale plaintext value.
|
||||||
|
func testLegacyUserDefaultsKeyIsMigratedToKeychain() {
|
||||||
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
|
// Pre-upgrade state: apiKey lives in UserDefaults.
|
||||||
|
defaults.set("sk-legacy-plaintext", forKey: "config.apiKey")
|
||||||
|
|
||||||
|
// First init after upgrade: triggers the one-shot migration.
|
||||||
|
let config = ProviderConfig(defaults: defaults)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.apiKey, "sk-legacy-plaintext",
|
||||||
|
"Migrated key must surface through ProviderConfig")
|
||||||
|
XCTAssertEqual(Keychain.apiKey(), "sk-legacy-plaintext",
|
||||||
|
"Legacy value must land in Keychain after migration")
|
||||||
|
XCTAssertNil(defaults.string(forKey: "config.apiKey"),
|
||||||
|
"Legacy UserDefaults entry must be cleared after migration")
|
||||||
|
|
||||||
|
// Second init (no legacy value left) reads from Keychain only.
|
||||||
|
let config2 = ProviderConfig(defaults: defaults)
|
||||||
|
XCTAssertEqual(config2.apiKey, "sk-legacy-plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If both the Keychain and the legacy UserDefaults slot have a value
|
||||||
|
/// — possible on a downgrade or a torn update — Keychain wins. We
|
||||||
|
/// don't delete the UserDefaults value, but the running app reads from
|
||||||
|
/// the Keychain only.
|
||||||
|
func testKeychainWinsOverLegacyWhenBothPresent() {
|
||||||
|
let suiteName = "group.com.osgkeyboard.shared.tests.migration2.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
|
// Set both.
|
||||||
|
try? Keychain.setAPIKey("sk-new")
|
||||||
|
defaults.set("sk-old", forKey: "config.apiKey")
|
||||||
|
|
||||||
|
let config = ProviderConfig(defaults: defaults)
|
||||||
|
XCTAssertEqual(config.apiKey, "sk-new", "Keychain value must take precedence")
|
||||||
|
// Migration only fires when Keychain was nil — we did NOT delete
|
||||||
|
// the legacy entry here. That's fine because Keychain is the
|
||||||
|
// source of truth from now on; the legacy value is dead weight
|
||||||
|
// but not incorrect.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,24 @@ import XCTest
|
|||||||
|
|
||||||
final class LLMClientTests: XCTestCase {
|
final class LLMClientTests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// The Keychain is process-global in the simulator (one simulator,
|
||||||
|
// one keychain DB), so an API key written by a previous test would
|
||||||
|
// leak into the next one unless we wipe it here. We intentionally
|
||||||
|
// swallow errors — `errSecItemNotFound` is fine.
|
||||||
|
try? Keychain.deleteAPIKey()
|
||||||
|
StubURLProtocolStorage.config = nil
|
||||||
|
StubURLProtocolStorage.delaySeconds = 0
|
||||||
|
StubURLProtocolStorage.lastRequest = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
try? Keychain.deleteAPIKey()
|
||||||
|
StubURLProtocolStorage.config = nil
|
||||||
|
StubURLProtocolStorage.delaySeconds = 0
|
||||||
|
StubURLProtocolStorage.lastRequest = nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - ProviderConfig persistence
|
// MARK: - ProviderConfig persistence
|
||||||
|
|
||||||
func testProviderConfigPersistsAcrossInstances() {
|
func testProviderConfigPersistsAcrossInstances() {
|
||||||
|
|||||||
+12
@@ -42,6 +42,13 @@ targets:
|
|||||||
com.apple.security.application-groups:
|
com.apple.security.application-groups:
|
||||||
- group.com.osgkeyboard.shared
|
- group.com.osgkeyboard.shared
|
||||||
com.apple.security.device.audio-input: true
|
com.apple.security.device.audio-input: true
|
||||||
|
# Shared Keychain access group: the host app writes the LLM API
|
||||||
|
# key here in Settings; the keyboard extension reads it before
|
||||||
|
# each request. The first entry below becomes each process's
|
||||||
|
# default access group, so the Keychain helper does not need to
|
||||||
|
# specify kSecAttrAccessGroup explicitly.
|
||||||
|
com.apple.security.keychain-access-groups:
|
||||||
|
- com.osgkeyboard.shared
|
||||||
resources:
|
resources:
|
||||||
- path: OSGKeyboard/Assets.xcassets
|
- path: OSGKeyboard/Assets.xcassets
|
||||||
info:
|
info:
|
||||||
@@ -91,6 +98,11 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
com.apple.security.application-groups:
|
com.apple.security.application-groups:
|
||||||
- group.com.osgkeyboard.shared
|
- group.com.osgkeyboard.shared
|
||||||
|
# Shared with the host app so the keyboard extension can read the
|
||||||
|
# user's LLM API key. See OSGKeyboard/OSGKeyboard.entitlements
|
||||||
|
# for the full rationale.
|
||||||
|
com.apple.security.keychain-access-groups:
|
||||||
|
- com.osgkeyboard.shared
|
||||||
info:
|
info:
|
||||||
path: OSGKeyboardExt/Info.plist
|
path: OSGKeyboardExt/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
Reference in New Issue
Block a user