Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sync status indicator in welcome window showing real-time sync state
- Conflict resolution dialog for handling simultaneous edits across devices

### Fixed

- Keychain authorization prompt no longer appears on every table open

## [0.18.1] - 2026-03-14

### Fixed
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {

func applicationDidFinishLaunching(_ notification: Notification) {
NSWindow.allowsAutomaticWindowTabbing = true
KeychainHelper.shared.migrateFromLegacyKeychainIfNeeded()
PluginManager.shared.loadPlugins()

Task { @MainActor in
Expand Down
58 changes: 3 additions & 55 deletions TablePro/Core/Storage/AIKeyStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,10 @@
//

import Foundation
import os
import Security

/// Singleton Keychain storage for AI provider API keys
final class AIKeyStorage {
static let shared = AIKeyStorage()
private static let logger = Logger(subsystem: "com.TablePro", category: "AIKeyStorage")

private init() {}

Expand All @@ -22,67 +19,18 @@ final class AIKeyStorage {
/// Save an API key to Keychain for the given provider
func saveAPIKey(_ apiKey: String, for providerID: UUID) {
let key = "com.TablePro.aikey.\(providerID.uuidString)"

// Delete existing
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]
SecItemDelete(deleteQuery as CFDictionary)

// Add new
guard let data = apiKey.data(using: .utf8) else { return }

let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]

let status = SecItemAdd(addQuery as CFDictionary, nil)
if status != errSecSuccess {
Self.logger.error("Failed to save API key for provider \(providerID.uuidString): \(status)")
}
KeychainHelper.shared.saveString(apiKey, forKey: key)
}

/// Load an API key from Keychain for the given provider
func loadAPIKey(for providerID: UUID) -> String? {
let key = "com.TablePro.aikey.\(providerID.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let apiKey = String(data: data, encoding: .utf8)
else {
return nil
}

return apiKey
return KeychainHelper.shared.loadString(forKey: key)
}

/// Delete an API key from Keychain for the given provider
func deleteAPIKey(for providerID: UUID) {
let key = "com.TablePro.aikey.\(providerID.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

SecItemDelete(query as CFDictionary)
KeychainHelper.shared.delete(key: key)
}
}
178 changes: 12 additions & 166 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import Foundation
import os
import Security

/// Service for persisting database connections
final class ConnectionStorage {
Expand Down Expand Up @@ -162,223 +161,70 @@ final class ConnectionStorage {
// - ConnectionFormView — single-item lookup during form population (negligible latency)
// No async wrapper is needed; adding one would add complexity without measurable benefit.

/// Upsert a value into the Keychain: tries SecItemAdd first, falls back to SecItemUpdate
/// on duplicate. Returns true on success.
@discardableResult
private func keychainUpsert(key: String, data: Data) -> Bool {
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

let addQuery = baseQuery.merging([
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]) { _, new in new }

let addStatus = SecItemAdd(addQuery as CFDictionary, nil)

if addStatus == errSecDuplicateItem {
// Item already exists — update it
let updateAttrs: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
if updateStatus != errSecSuccess {
Self.logger.error("Failed to update Keychain item '\(key)': OSStatus \(updateStatus)")
return false
}
return true
} else if addStatus != errSecSuccess {
Self.logger.error("Failed to add Keychain item '\(key)': OSStatus \(addStatus)")
return false
}
return true
}

/// Save password to Keychain
func savePassword(_ password: String, for connectionId: UUID) {
let key = "com.TablePro.password.\(connectionId.uuidString)"
guard let data = password.data(using: .utf8) else { return }
keychainUpsert(key: key, data: data)
KeychainHelper.shared.saveString(password, forKey: key)
}

/// Load password from Keychain
func loadPassword(for connectionId: UUID) -> String? {
let key = "com.TablePro.password.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
return nil
}

return password
return KeychainHelper.shared.loadString(forKey: key)
}

/// Delete password from Keychain
func deletePassword(for connectionId: UUID) {
let key = "com.TablePro.password.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

SecItemDelete(query as CFDictionary)
KeychainHelper.shared.delete(key: key)
}

// MARK: - SSH Password Storage

/// Save SSH password to Keychain
func saveSSHPassword(_ password: String, for connectionId: UUID) {
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"
guard let data = password.data(using: .utf8) else { return }
keychainUpsert(key: key, data: data)
KeychainHelper.shared.saveString(password, forKey: key)
}

/// Load SSH password from Keychain
func loadSSHPassword(for connectionId: UUID) -> String? {
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8)
else {
return nil
}

return password
return KeychainHelper.shared.loadString(forKey: key)
}

/// Delete SSH password from Keychain
func deleteSSHPassword(for connectionId: UUID) {
let key = "com.TablePro.sshpassword.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

SecItemDelete(query as CFDictionary)
KeychainHelper.shared.delete(key: key)
}

// MARK: - Key Passphrase Storage

/// Save private key passphrase to Keychain
func saveKeyPassphrase(_ passphrase: String, for connectionId: UUID) {
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"
guard let data = passphrase.data(using: .utf8) else { return }
keychainUpsert(key: key, data: data)
KeychainHelper.shared.saveString(passphrase, forKey: key)
}

/// Load private key passphrase from Keychain
func loadKeyPassphrase(for connectionId: UUID) -> String? {
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let passphrase = String(data: data, encoding: .utf8)
else {
return nil
}

return passphrase
return KeychainHelper.shared.loadString(forKey: key)
}

/// Delete private key passphrase from Keychain
func deleteKeyPassphrase(for connectionId: UUID) {
let key = "com.TablePro.keypassphrase.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

SecItemDelete(query as CFDictionary)
KeychainHelper.shared.delete(key: key)
}

// MARK: - TOTP Secret Storage

/// Save TOTP secret to Keychain
func saveTOTPSecret(_ secret: String, for connectionId: UUID) {
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"
guard let data = secret.data(using: .utf8) else { return }
keychainUpsert(key: key, data: data)
KeychainHelper.shared.saveString(secret, forKey: key)
}

/// Load TOTP secret from Keychain
func loadTOTPSecret(for connectionId: UUID) -> String? {
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

guard status == errSecSuccess,
let data = result as? Data,
let secret = String(data: data, encoding: .utf8)
else {
return nil
}

return secret
return KeychainHelper.shared.loadString(forKey: key)
}

/// Delete TOTP secret from Keychain
func deleteTOTPSecret(for connectionId: UUID) {
let key = "com.TablePro.totpsecret.\(connectionId.uuidString)"

let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.TablePro",
kSecAttrAccount as String: key,
]

SecItemDelete(query as CFDictionary)
KeychainHelper.shared.delete(key: key)
}
}

Expand Down
Loading
Loading