Skip to content

Commit d5554b9

Browse files
authored
fix(keychain): remove destructive migration + Apple-correct API refactor (#972)
* fix(keychain): remove destructive legacy migration that wiped saved passwords * refactor(keychain): consolidate API, tighten accessibility for non-sync items
1 parent b4be6b7 commit d5554b9

12 files changed

Lines changed: 243 additions & 359 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Oracle Test Connection now opens a focused diagnostic sheet for auth failures with copy-able diagnostic info, suggested actions, and a link to file an issue
1414
- Oracle connection negotiation now matches python-oracledb's 23ai compile-capability advertisement, including TTC4 explicit boundary, TTC5 token/pipelining/sessionless flags, OCI3 sync, dequeue selectors, and sparse vector features
1515

16+
### Removed
17+
18+
- Keychain: the legacy-keychain migration (`migrateFromLegacyKeychainIfNeeded`) and the password-sync-state migration (`migratePasswordSyncState`). The first violated Apple's Data Protection keychain contract on sandboxed macOS apps and corrupted user credentials; the second toggled `kSecAttrSynchronizable` at runtime, which Apple does not document as safe. The Sync Passwords settings toggle now applies to new saves only — existing keychain items keep their original sync state, matching Apple's documented behavior. Users with stale items in the legacy keychain can clean them via Keychain Access; the running app no longer touches them.
19+
1620
### Changed
1721

1822
- Internal: introduce `TabSession` as the foundation type for the editor tab/window subsystem rewrite. Currently a parallel structure mirroring `QueryTab`; subsequent PRs migrate state ownership and lifecycle hooks per `docs/architecture/tab-subsystem-rewrite.md`. No user-visible behavior change in this PR.
1923
- Internal: row data and load epoch now live on `TabSession`. `TabSessionRegistry` exposes the row-access methods directly (`tableRows(for:)`, `setTableRows(_:for:)`, `evict(for:)`, etc.); the intermediate `TableRowsStore` facade is gone. All consumers (coordinator, extensions, views, command actions) now read row data from the registry. No user-visible behavior change.
2024
- Internal: hidden-column state moves from the per-window `ColumnVisibilityManager` into each tab's `columnLayout.hiddenColumns`. The shared manager is removed; `MainContentCoordinator` exposes `hideColumn`, `showColumn`, `toggleColumnVisibility`, `showAllColumns`, `hideAllColumns`, and `pruneHiddenColumns` that mutate the active tab directly. Per-table UserDefaults persistence moves into a small `ColumnVisibilityPersistence` service. Tab-switch save/restore swap is gone — each tab is its own source of truth. No user-visible behavior change.
2125
- Internal: filter state collapses from three places (the per-window `FilterStateManager`, the `TabFilterState` snapshot on `QueryTab`, and the per-table file-based restore) to a single source: `tab.filterState`. The shared manager is removed; `MainContentCoordinator` now exposes the full filter API (`addFilter`, `applyAllFilters`, `clearFilterState`, `toggleFilterPanel`, `setFKFilter`, `saveLastFilters(for:)`, `restoreLastFilters(for:)`, `saveFilterPreset`, `loadFilterPreset`, `generateFilterPreviewSQL`, etc.) that mutates the active tab. The file-based "restore last filters" persistence in `FilterSettingsStorage` is unchanged. `FilterPanelView`, `MainStatusBarView`, `MainContentCommandActions`, `MainContentView`, and `MainEditorContentView` read filter state directly off the active tab. No user-visible behavior change.
2226
- Internal: extract `QueryExecutor` service from `MainContentCoordinator`. Query data fetch, parallel schema fetch, schema parsing, parameter detection, row-cap policy, and DDL detection now live in `TablePro/Core/Services/Query/QueryExecutor.swift`. SQL parsing helpers (`extractTableName`, `stripTrailingOrderBy`, `parseSQLiteCheckConstraintValues`) move into `QuerySqlParser`. Coordinator methods become thin wrappers; behavior unchanged. No user-visible behavior change.
27+
- Security: non-syncing keychain items now use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. This keeps local-only secrets out of unencrypted device backups (the pairing Apple recommends for local secrets). Syncing items still use `kSecAttrAccessibleAfterFirstUnlock` because iCloud Keychain requires it. Existing items keep their accessibility class until you save them again.
28+
- Internal: `KeychainHelper` API consolidated. `save(key:data:)`/`saveString(_:forKey:)` become `write(_:forKey:)`/`writeString(_:forKey:)`; `load(key:)`/`loadWithStatus(key:)`/`loadString(forKey:)`/`loadStringWithStatus(forKey:)` become `read(forKey:)`/`readString(forKey:)`/`readStringResult(forKey:)`, all returning a typed `KeychainResult`/`KeychainStringResult` enum with `.found`/`.notFound`/`.locked` cases. `delete(key:)` renamed to `delete(forKey:)`. All consumers (`ConnectionStorage`, `SSHProfileStorage`, `AIKeyStorage`, `LicenseStorage`) updated to log when the keychain is locked rather than silently returning nil. `KeychainHelper` is now `Sendable`. Failure logging uses `SecCopyErrorMessageString`.
29+
- Settings > Sync > Passwords shows a caption explaining the toggle only affects new saves. Existing passwords keep their current sync state until you re-save them.
2330

2431
### Fixed
2532

33+
- Saved connection passwords no longer disappear after quitting and relaunching the app. The legacy-keychain migration that ran on every launch was destructive on sandboxed macOS configurations: queries without `kSecUseDataProtectionKeychain` returned items that had been written *with* the flag, and the migration's "delete legacy entry" step then removed the only copy. Removed the legacy keychain migration entirely; `KeychainHelper` now exclusively reads and writes through the Data Protection keychain on every launch.
2634
- Tab switching: rapid Cmd+Number presses no longer leave a tail of tab transitions playing after the user releases the keys. The tab-selection setter (`NSWindowTabGroup.selectedWindow`) is now wrapped in `NSAnimationContext.runAnimationGroup` with `duration = 0`, so AppKit applies each switch synchronously without queuing a CAAnimation. Lazy-load also moved out of `windowDidBecomeKey` into `.task(id:)` view-appearance lifecycle per Apple's documentation. Note: extreme Cmd+Number bursts (e.g. holding the key for key-repeat) still incur per-switch AppKit window-focus overhead; this is platform-inherent to native NSWindow tabs and documented in `docs/architecture/tab-subsystem-rewrite.md` D2
2735
- Oracle TIMESTAMP, TIMESTAMP WITH TIME ZONE, TIMESTAMP WITH LOCAL TIME ZONE, INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH, DATE, RAW, and BLOB columns now render through typed decoders instead of garbled text. Tables containing INTERVAL YEAR TO MONTH or BFILE columns no longer crash the app on row fetch. Unknown column types display `<unsupported: type>` instead of crashing (#965)
2836
- Oracle connections to 23ai cloud and containerized deployments no longer fail with `uncleanShutdown` mid-handshake. OOB urgent-byte send now requires the server to advertise `TNS_ACCEPT_FLAG_CHECK_OOB`, matching python-oracledb behavior (#483)

TablePro/AppDelegate.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4141
NSWindow.allowsAutomaticWindowTabbing = true
4242
let syncSettings = AppSettingsStorage.shared.loadSync()
4343
let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords
44-
let previousSyncState = UserDefaults.standard.bool(forKey: KeychainHelper.passwordSyncEnabledKey)
4544
UserDefaults.standard.set(passwordSyncExpected, forKey: KeychainHelper.passwordSyncEnabledKey)
46-
Task.detached(priority: .utility) {
47-
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
48-
}
49-
if passwordSyncExpected != previousSyncState {
50-
Task.detached(priority: .background) {
51-
KeychainHelper.shared.migratePasswordSyncState(synchronizable: passwordSyncExpected)
52-
}
53-
}
5445
DatabaseManager.shared.startObservingSystemEvents()
5546

5647
MemoryPressureAdvisor.startMonitoring()

TablePro/Core/Storage/AIKeyStorage.swift

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,37 @@
77
//
88

99
import Foundation
10+
import os
1011

11-
/// Singleton Keychain storage for AI provider API keys
1212
final class AIKeyStorage {
1313
static let shared = AIKeyStorage()
1414

15-
private init() {}
15+
private static let logger = Logger(subsystem: "com.TablePro", category: "AIKeyStorage")
1616

17-
// MARK: - API Key Operations
17+
private init() {}
1818

19-
/// Save an API key to Keychain for the given provider
2019
func saveAPIKey(_ apiKey: String, for providerID: UUID) {
2120
let key = "com.TablePro.aikey.\(providerID.uuidString)"
22-
KeychainHelper.shared.saveString(apiKey, forKey: key)
21+
KeychainHelper.shared.writeString(apiKey, forKey: key)
2322
}
2423

25-
/// Load an API key from Keychain for the given provider
2624
func loadAPIKey(for providerID: UUID) -> String? {
2725
let key = "com.TablePro.aikey.\(providerID.uuidString)"
28-
return KeychainHelper.shared.loadString(forKey: key)
26+
switch KeychainHelper.shared.readStringResult(forKey: key) {
27+
case .found(let value):
28+
return value
29+
case .locked:
30+
Self.logger.warning(
31+
"AI API key unavailable — Keychain locked (providerID=\(providerID.uuidString, privacy: .public))"
32+
)
33+
return nil
34+
case .notFound:
35+
return nil
36+
}
2937
}
3038

31-
/// Delete an API key from Keychain for the given provider
3239
func deleteAPIKey(for providerID: UUID) {
3340
let key = "com.TablePro.aikey.\(providerID.uuidString)"
34-
KeychainHelper.shared.delete(key: key)
41+
KeychainHelper.shared.delete(forKey: key)
3542
}
3643
}

TablePro/Core/Storage/ConnectionStorage.swift

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -264,80 +264,68 @@ final class ConnectionStorage {
264264

265265
func savePassword(_ password: String, for connectionId: UUID) {
266266
let key = "com.TablePro.password.\(connectionId.uuidString)"
267-
KeychainHelper.shared.saveString(password, forKey: key)
267+
KeychainHelper.shared.writeString(password, forKey: key)
268268
}
269269

270270
func loadPassword(for connectionId: UUID) -> String? {
271271
let key = "com.TablePro.password.\(connectionId.uuidString)"
272-
let (value, isLocked) = KeychainHelper.shared.loadStringWithStatus(forKey: key)
273-
if isLocked {
274-
Self.logger.warning("Database password unavailable — Keychain locked (connId=\(connectionId.uuidString, privacy: .public))")
275-
}
276-
return value
272+
return resolveString(.init(label: "Database password", connectionId: connectionId), forKey: key)
277273
}
278274

279275
func deletePassword(for connectionId: UUID) {
280276
let key = "com.TablePro.password.\(connectionId.uuidString)"
281-
KeychainHelper.shared.delete(key: key)
277+
KeychainHelper.shared.delete(forKey: key)
282278
}
283279

284280
// MARK: - SSH Password Storage
285281

286282
func saveSSHPassword(_ password: String, for connectionId: UUID) {
287283
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
288-
KeychainHelper.shared.saveString(password, forKey: key)
284+
KeychainHelper.shared.writeString(password, forKey: key)
289285
}
290286

291287
func loadSSHPassword(for connectionId: UUID) -> String? {
292288
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
293-
let (value, isLocked) = KeychainHelper.shared.loadStringWithStatus(forKey: key)
294-
if isLocked {
295-
Self.logger.warning("SSH password unavailable — Keychain locked (connId=\(connectionId.uuidString, privacy: .public))")
296-
}
297-
return value
289+
return resolveString(.init(label: "SSH password", connectionId: connectionId), forKey: key)
298290
}
299291

300292
func deleteSSHPassword(for connectionId: UUID) {
301293
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
302-
KeychainHelper.shared.delete(key: key)
294+
KeychainHelper.shared.delete(forKey: key)
303295
}
304296

305297
// MARK: - Key Passphrase Storage
306298

307299
func saveKeyPassphrase(_ passphrase: String, for connectionId: UUID) {
308300
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
309-
KeychainHelper.shared.saveString(passphrase, forKey: key)
301+
KeychainHelper.shared.writeString(passphrase, forKey: key)
310302
}
311303

312304
func loadKeyPassphrase(for connectionId: UUID) -> String? {
313305
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
314-
let (value, isLocked) = KeychainHelper.shared.loadStringWithStatus(forKey: key)
315-
if isLocked {
316-
Self.logger.warning("Key passphrase unavailable — Keychain locked (connId=\(connectionId.uuidString, privacy: .public))")
317-
}
318-
return value
306+
return resolveString(.init(label: "Key passphrase", connectionId: connectionId), forKey: key)
319307
}
320308

321309
func deleteKeyPassphrase(for connectionId: UUID) {
322310
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
323-
KeychainHelper.shared.delete(key: key)
311+
KeychainHelper.shared.delete(forKey: key)
324312
}
325313

326314
// MARK: - Plugin Secure Field Storage
327315

328316
func savePluginSecureField(_ value: String, fieldId: String, for connectionId: UUID) {
329317
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
330-
KeychainHelper.shared.saveString(value, forKey: key)
318+
KeychainHelper.shared.writeString(value, forKey: key)
331319
}
332320

333321
func loadPluginSecureField(fieldId: String, for connectionId: UUID) -> String? {
334322
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
335-
return KeychainHelper.shared.loadString(forKey: key)
323+
return resolveString(.init(label: "Plugin field \(fieldId)", connectionId: connectionId), forKey: key)
336324
}
337325

338326
func deletePluginSecureField(fieldId: String, for connectionId: UUID) {
339327
let key = "com.TablePro.plugin.\(fieldId).\(connectionId.uuidString)"
340-
KeychainHelper.shared.delete(key: key)
328+
KeychainHelper.shared.delete(forKey: key)
341329
}
342330

343331
func deleteAllPluginSecureFields(for connectionId: UUID, fieldIds: [String]) {
@@ -350,17 +338,36 @@ final class ConnectionStorage {
350338

351339
func saveTOTPSecret(_ secret: String, for connectionId: UUID) {
352340
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
353-
KeychainHelper.shared.saveString(secret, forKey: key)
341+
KeychainHelper.shared.writeString(secret, forKey: key)
354342
}
355343

356344
func loadTOTPSecret(for connectionId: UUID) -> String? {
357345
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
358-
return KeychainHelper.shared.loadString(forKey: key)
346+
return resolveString(.init(label: "TOTP secret", connectionId: connectionId), forKey: key)
359347
}
360348

361349
func deleteTOTPSecret(for connectionId: UUID) {
362350
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
363-
KeychainHelper.shared.delete(key: key)
351+
KeychainHelper.shared.delete(forKey: key)
352+
}
353+
354+
private struct SecretContext {
355+
let label: String
356+
let connectionId: UUID
357+
}
358+
359+
private func resolveString(_ context: SecretContext, forKey key: String) -> String? {
360+
switch KeychainHelper.shared.readStringResult(forKey: key) {
361+
case .found(let value):
362+
return value
363+
case .locked:
364+
Self.logger.warning(
365+
"\(context.label, privacy: .public) unavailable — Keychain locked (connId=\(context.connectionId.uuidString, privacy: .public))"
366+
)
367+
return nil
368+
case .notFound:
369+
return nil
370+
}
364371
}
365372

366373
// MARK: - Plugin Secure Field Migration

0 commit comments

Comments
 (0)