From e0c8d2a9d7c1259dde67dc01a0a6bb64c6f82914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 15:38:06 +0700 Subject: [PATCH 1/3] feat(import): import connections from Beekeeper Studio --- CHANGELOG.md | 1 + .../ForeignApp/BeekeeperEncryptor.swift | 119 ++++++ .../ForeignApp/BeekeeperStudioImporter.swift | 390 ++++++++++++++++++ .../ForeignApp/ForeignAppImporter.swift | 3 +- .../ForeignApp/DBeaverImporterTests.swift | 6 +- .../ForeignAppImporterRegistryTests.swift | 11 +- 6 files changed, 525 insertions(+), 5 deletions(-) create mode 100644 TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift create mode 100644 TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d847d625d..c32ad7c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import connections from Beekeeper Studio. Reads `app.db` from the local workspace, decrypts saved passwords using Beekeeper's two-tier key scheme, and maps SSH bastion hosts to TablePro's jump-host field - Schema picker at the bottom of the Tables sidebar to switch the active schema (#1296) - Inline dropdown picker when editing ENUM and SET columns, covering MySQL, MariaDB, PostgreSQL, ClickHouse, DuckDB, and MongoDB JSON-schema enums (#1283) - Filter rows show an enum dropdown for `=` and `!=` operators on enum columns (#1283) diff --git a/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift b/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift new file mode 100644 index 000000000..2e2debb92 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift @@ -0,0 +1,119 @@ +// +// BeekeeperEncryptor.swift +// TablePro +// +// Re-implements Beekeeper Studio's `simple-encryptor` (Node.js) format so +// TablePro can read passwords from a Beekeeper `app.db` during import. +// +// Format produced by `simple-encryptor` with HMAC enabled (Beekeeper's +// default): +// +// +// +// - Encryption key is `SHA-256(rawKeyString)` (32 bytes) +// - Cipher is AES-256-CBC with PKCS#7 padding +// - Plaintext is `JSON.stringify(value)` so single string passwords come back +// wrapped in JSON quotes that must be stripped +// +// Beekeeper bootstraps its real key with a hardcoded one. The hardcoded +// default decrypts `~/Library/Application Support/beekeeper-studio/.key`, +// whose plaintext is `{"encryptionKey":"<64-char hex>"}`. That hex string +// (passed verbatim, NOT hex-decoded — `simple-encryptor` always SHA-256s the +// input) is then used to decrypt every password column. +// + +import CommonCrypto +import Foundation + +enum BeekeeperEncryptor { + /// Hardcoded bootstrap key from Beekeeper Studio source. Used only to + /// unwrap the per-install user key from the `.key` file. + static let defaultKey = "38782F413F442A472D4B6150645367566B59703373367639792442264529482B" + + /// Decrypts a `simple-encryptor` payload with the given raw key string + /// and returns the JSON-decoded plaintext as a Swift value. Returns nil + /// if the payload is malformed or decryption fails. + static func decrypt(_ payload: String, key: String) -> Any? { + guard payload.count > 96 else { return nil } + let ivHexStart = payload.index(payload.startIndex, offsetBy: 64) + let cipherStart = payload.index(payload.startIndex, offsetBy: 96) + let ivHex = String(payload[ivHexStart.. String? { + decrypt(payload, key: key) as? String + } + + // MARK: - Primitives + + private static func sha256(_ data: Data) -> Data { + var hash = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + hash.withUnsafeMutableBytes { hashBytes in + data.withUnsafeBytes { dataBytes in + _ = CC_SHA256(dataBytes.baseAddress, CC_LONG(data.count), hashBytes.bindMemory(to: UInt8.self).baseAddress) + } + } + return hash + } + + private static func aes256CBCDecrypt(_ ciphertext: Data, key: Data, iv: Data) -> Data? { + let bufferSize = ciphertext.count + kCCBlockSizeAES128 + var buffer = Data(count: bufferSize) + var decryptedSize = 0 + + let status = buffer.withUnsafeMutableBytes { bufferBytes -> CCCryptorStatus in + ciphertext.withUnsafeBytes { cipherBytes in + iv.withUnsafeBytes { ivBytes in + key.withUnsafeBytes { keyBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, kCCKeySizeAES256, + ivBytes.baseAddress, + cipherBytes.baseAddress, ciphertext.count, + bufferBytes.baseAddress, bufferSize, + &decryptedSize + ) + } + } + } + } + guard status == kCCSuccess else { return nil } + return buffer.prefix(decryptedSize) + } +} + +private extension Data { + init?(hex: String) { + let cleaned = hex.lowercased() + guard cleaned.count.isMultiple(of: 2) else { return nil } + var data = Data(capacity: cleaned.count / 2) + var index = cleaned.startIndex + while index < cleaned.endIndex { + let next = cleaned.index(index, offsetBy: 2) + guard let byte = UInt8(cleaned[index.. Int { + (try? readSavedConnections().count) ?? 0 + } + + func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult { + let rows: [SavedConnectionRow] + do { + rows = try readSavedConnections() + } catch let error as ForeignAppImportError { + throw error + } catch { + throw ForeignAppImportError.parseError(error.localizedDescription) + } + + let folderMap = (try? readConnectionFolders()) ?? [:] + let userKey = includePasswords ? loadUserEncryptionKey() : nil + + var exportableConnections: [ExportableConnection] = [] + var groupNames: Set = [] + var credentials: [String: ExportableCredentials] = [:] + + for row in rows { + guard let type = Self.mapDriver(row.connectionType) else { + Self.logger.warning("Skipping Beekeeper connection \(row.id) with unsupported driver \(row.connectionType ?? "", privacy: .public)") + continue + } + let groupName = row.connectionFolderId.flatMap { folderMap[$0] } + if let groupName { groupNames.insert(groupName) } + + let exportable = ExportableConnection( + name: row.name.isEmpty ? "Untitled" : row.name, + host: row.host.isEmpty ? "localhost" : row.host, + port: row.port ?? Self.defaultPort(for: type), + database: row.defaultDatabase ?? "", + username: row.username ?? "", + type: type, + sshConfig: row.sshEnabled ? Self.buildSSHConfig(row) : nil, + sslConfig: row.ssl ? Self.buildSSLConfig(row) : nil, + color: Self.mapColor(row.labelColor), + tagName: nil, + groupName: groupName, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + let index = exportableConnections.count + exportableConnections.append(exportable) + + if includePasswords, let userKey { + let creds = Self.extractCredentials(row: row, key: userKey) + if creds.password != nil || creds.sshPassword != nil || creds.keyPassphrase != nil { + credentials[String(index)] = creds + } + } + } + + guard !exportableConnections.isEmpty else { + throw ForeignAppImportError.noConnectionsFound + } + + let groups = groupNames.isEmpty ? nil : groupNames.sorted().map { ExportableGroup(name: $0, color: nil) } + + let envelope = ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "Beekeeper Studio Import", + connections: exportableConnections, + groups: groups, + tags: nil, + credentials: credentials.isEmpty ? nil : credentials + ) + + return ForeignAppImportResult(envelope: envelope, sourceName: displayName) + } + + // MARK: - Driver Mapping + + private static func mapDriver(_ raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + switch raw.lowercased() { + case "mysql": return "MySQL" + case "mariadb": return "MariaDB" + case "postgresql", "postgres": return "PostgreSQL" + case "redshift": return "Redshift" + case "cockroachdb": return "CockroachDB" + case "sqlite": return "SQLite" + case "sqlserver": return "SQL Server" + case "oracle": return "Oracle" + case "mongodb", "mongo": return "MongoDB" + case "redis": return "Redis" + case "cassandra": return "Cassandra" + case "clickhouse": return "ClickHouse" + case "bigquery": return "BigQuery" + case "duckdb": return "DuckDB" + case "libsql": return "libSQL" + default: return nil + } + } + + private static func defaultPort(for type: String) -> Int { + switch type { + case "MySQL", "MariaDB": return 3306 + case "PostgreSQL", "Redshift": return 5432 + case "CockroachDB": return 26257 + case "SQL Server": return 1433 + case "Oracle": return 1521 + case "MongoDB": return 27017 + case "Redis": return 6379 + case "Cassandra": return 9042 + case "ClickHouse": return 8123 + default: return 0 + } + } + + // MARK: - SSH Mapping + + private static func buildSSHConfig(_ row: SavedConnectionRow) -> ExportableSSHConfig { + let bastion: ExportableJumpHost? = { + guard let host = row.sshBastionHost, !host.isEmpty else { return nil } + return ExportableJumpHost( + host: host, + port: row.sshBastionPort, + username: row.sshBastionUsername ?? "", + authMethod: mapSSHAuth(row.sshBastionMode), + privateKeyPath: ForeignAppPathHelper.resolveKeyPath(row.sshBastionKeyfile ?? "") + ) + }() + + return ExportableSSHConfig( + enabled: true, + host: row.sshHost ?? "", + port: row.sshPort, + username: row.sshUsername ?? "", + authMethod: mapSSHAuth(row.sshMode), + privateKeyPath: ForeignAppPathHelper.resolveKeyPath(row.sshKeyfile ?? ""), + agentSocketPath: "", + jumpHosts: bastion.map { [$0] }, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + } + + private static func mapSSHAuth(_ mode: String?) -> String { + switch mode?.lowercased() { + case "keyfile": return "Private Key" + case "agent": return "SSH Agent" + default: return "Password" + } + } + + // MARK: - SSL Mapping + + private static func buildSSLConfig(_ row: SavedConnectionRow) -> ExportableSSLConfig { + let mode = row.sslRejectUnauthorized && !row.trustServerCertificate + ? "Verify Identity" + : "Required" + return ExportableSSLConfig( + mode: mode, + caCertificatePath: row.sslCaFile, + clientCertificatePath: row.sslCertFile, + clientKeyPath: row.sslKeyFile + ) + } + + // MARK: - Color + + private static func mapColor(_ raw: String?) -> String? { + switch raw?.lowercased() { + case "red": return "Red" + case "orange": return "Orange" + case "yellow": return "Yellow" + case "green": return "Green" + case "blue": return "Blue" + case "purple": return "Purple" + case "pink": return "Pink" + case "gray", "grey": return "Gray" + default: return nil + } + } + + // MARK: - Credentials + + private func loadUserEncryptionKey() -> String? { + guard let data = try? Data(contentsOf: keyFileURL), + let payload = String(data: data, encoding: .utf8), + let decoded = BeekeeperEncryptor.decrypt(payload, key: BeekeeperEncryptor.defaultKey) as? [String: Any], + let key = decoded["encryptionKey"] as? String else { + return nil + } + return key + } + + private static func extractCredentials(row: SavedConnectionRow, key: String) -> ExportableCredentials { + ExportableCredentials( + password: decrypt(row.password, key: key), + sshPassword: decrypt(row.sshPassword, key: key), + keyPassphrase: decrypt(row.sshKeyfilePassword, key: key), + totpSecret: nil, + pluginSecureFields: nil + ) + } + + private static func decrypt(_ value: String?, key: String) -> String? { + guard let value, !value.isEmpty else { return nil } + return BeekeeperEncryptor.decryptString(value, key: key) + } + + // MARK: - SQLite Reading + + private struct SavedConnectionRow { + let id: Int + let name: String + let connectionType: String? + let host: String + let port: Int? + let username: String? + let defaultDatabase: String? + let password: String? + let ssl: Bool + let sslCaFile: String? + let sslCertFile: String? + let sslKeyFile: String? + let sslRejectUnauthorized: Bool + let trustServerCertificate: Bool + let sshEnabled: Bool + let sshHost: String? + let sshPort: Int? + let sshUsername: String? + let sshMode: String? + let sshKeyfile: String? + let sshKeyfilePassword: String? + let sshPassword: String? + let sshBastionHost: String? + let sshBastionPort: Int? + let sshBastionUsername: String? + let sshBastionMode: String? + let sshBastionKeyfile: String? + let labelColor: String? + let connectionFolderId: Int? + } + + private func readSavedConnections() throws -> [SavedConnectionRow] { + let db = try openDatabase() + defer { sqlite3_close(db) } + + // Only personal workspace; cloud-synced rows have positive workspaceId. + let sql = """ + SELECT id, name, connectionType, host, port, username, defaultDatabase, password, + ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, trustServerCertificate, + sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, sshPassword, + sshBastionHost, sshBastionHostPort, sshBastionUsername, sshBastionMode, sshBastionKeyfile, + labelColor, connectionFolderId + FROM saved_connection + WHERE workspaceId = -1 + ORDER BY id + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw ForeignAppImportError.unsupportedFormat("saved_connection schema mismatch") + } + defer { sqlite3_finalize(statement) } + + var rows: [SavedConnectionRow] = [] + while sqlite3_step(statement) == SQLITE_ROW { + rows.append(SavedConnectionRow( + id: Int(sqlite3_column_int64(statement, 0)), + name: Self.text(statement, 1) ?? "", + connectionType: Self.text(statement, 2), + host: Self.text(statement, 3) ?? "", + port: Self.int(statement, 4), + username: Self.text(statement, 5), + defaultDatabase: Self.text(statement, 6), + password: Self.text(statement, 7), + ssl: Self.bool(statement, 8), + sslCaFile: Self.text(statement, 9), + sslCertFile: Self.text(statement, 10), + sslKeyFile: Self.text(statement, 11), + sslRejectUnauthorized: Self.bool(statement, 12), + trustServerCertificate: Self.bool(statement, 13), + sshEnabled: Self.bool(statement, 14), + sshHost: Self.text(statement, 15), + sshPort: Self.int(statement, 16), + sshUsername: Self.text(statement, 17), + sshMode: Self.text(statement, 18), + sshKeyfile: Self.text(statement, 19), + sshKeyfilePassword: Self.text(statement, 20), + sshPassword: Self.text(statement, 21), + sshBastionHost: Self.text(statement, 22), + sshBastionPort: Self.int(statement, 23), + sshBastionUsername: Self.text(statement, 24), + sshBastionMode: Self.text(statement, 25), + sshBastionKeyfile: Self.text(statement, 26), + labelColor: Self.text(statement, 27), + connectionFolderId: Self.int(statement, 28) + )) + } + return rows + } + + private func readConnectionFolders() throws -> [Int: String] { + let db = try openDatabase() + defer { sqlite3_close(db) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT id, name FROM connection_folder", -1, &statement, nil) == SQLITE_OK else { + return [:] + } + defer { sqlite3_finalize(statement) } + + var map: [Int: String] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + let id = Int(sqlite3_column_int64(statement, 0)) + if let name = Self.text(statement, 1), !name.isEmpty { + map[id] = name + } + } + return map + } + + private func openDatabase() throws -> OpaquePointer? { + guard FileManager.default.fileExists(atPath: appDatabaseURL.path) else { + throw ForeignAppImportError.fileNotFound(displayName) + } + var db: OpaquePointer? + // SQLITE_OPEN_READONLY avoids journal-file creation in another app's + // data directory. + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX + guard sqlite3_open_v2(appDatabaseURL.path, &db, flags, nil) == SQLITE_OK else { + sqlite3_close(db) + throw ForeignAppImportError.parseError("Could not open app.db") + } + return db + } + + private static func text(_ statement: OpaquePointer?, _ index: Int32) -> String? { + guard let cString = sqlite3_column_text(statement, index) else { return nil } + return String(cString: cString) + } + + private static func int(_ statement: OpaquePointer?, _ index: Int32) -> Int? { + guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil } + return Int(sqlite3_column_int64(statement, index)) + } + + private static func bool(_ statement: OpaquePointer?, _ index: Int32) -> Bool { + sqlite3_column_int(statement, index) != 0 + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 8f7db7f28..545b5726f 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -79,7 +79,8 @@ enum ForeignAppImporterRegistry { static let all: [any ForeignAppImporter] = [ TablePlusImporter(), SequelAceImporter(), - DBeaverImporter() + DBeaverImporter(), + BeekeeperStudioImporter() ] } diff --git a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift index ccab6207e..44d4ddd34 100644 --- a/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift +++ b/TableProTests/Core/Services/ForeignApp/DBeaverImporterTests.swift @@ -19,12 +19,12 @@ struct DBeaverImporterTests { tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("DBeaverImporterTests-\(UUID().uuidString)") - // DBeaver workspace structure: workspace6//.dbeaver/data-sources.json - projectDir = tempDir.appendingPathComponent("General/.dbeaver") + // DBeaver layout: /workspace6//.dbeaver/data-sources.json + projectDir = tempDir.appendingPathComponent("workspace6/General/.dbeaver") try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true) var imp = DBeaverImporter() - imp.workspaceBaseURL = tempDir + imp.dbeaverDataRoot = tempDir importer = imp } diff --git a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift index 275f03e54..3369ff9dd 100644 --- a/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift +++ b/TableProTests/Core/Services/ForeignApp/ForeignAppImporterRegistryTests.swift @@ -13,12 +13,13 @@ struct ForeignAppImporterRegistryTests { @Test("Registry contains all importers") func testRegistryContainsAllImporters() { let importers = ForeignAppImporterRegistry.all - #expect(importers.count == 3) + #expect(importers.count == 4) let ids = importers.map(\.id) #expect(ids.contains("tableplus")) #expect(ids.contains("sequelace")) #expect(ids.contains("dbeaver")) + #expect(ids.contains("beekeeperstudio")) } @Test("All importers have unique IDs") @@ -76,4 +77,12 @@ struct ForeignAppImporterRegistryTests { #expect(importer.displayName == "DBeaver") #expect(importer.appBundleIdentifier == "org.jkiss.dbeaver.core.product") } + + @Test("Beekeeper Studio importer has correct metadata") + func testBeekeeperStudioImporterMetadata() { + let importer = BeekeeperStudioImporter() + #expect(importer.id == "beekeeperstudio") + #expect(importer.displayName == "Beekeeper Studio") + #expect(importer.appBundleIdentifier == "io.beekeeperstudio.desktop") + } } From 7daddec4c25fe89e8ab6f4af11f33f97706c6c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 15:55:34 +0700 Subject: [PATCH 2/3] fix(import): show file path for SQLite/DuckDB instead of localhost:0 --- .../ConnectionImportPreviewList.swift | 16 +++++++++++++++- .../Views/Connection/WelcomeWindowView.swift | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index 817254dbb..2c694e9d9 100644 --- a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift +++ b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift @@ -57,7 +57,7 @@ struct ConnectionImportPreviewList: View { } } HStack(spacing: 0) { - Text(verbatim: "\(item.connection.host):\(item.connection.port)") + Text(verbatim: subtitle(for: item.connection)) warningText(for: item.status) } .font(.subheadline) @@ -112,4 +112,18 @@ struct ConnectionImportPreviewList: View { .foregroundStyle(.orange) } } + + /// File-based databases (SQLite, DuckDB) don't have a meaningful + /// host/port. Show the database path instead, abbreviating the home + /// directory the same way Finder does. + private func subtitle(for connection: ExportableConnection) -> String { + switch connection.type { + case "SQLite", "DuckDB": + return connection.database.isEmpty + ? connection.type + : (connection.database as NSString).abbreviatingWithTildeInPath + default: + return "\(connection.host):\(connection.port)" + } + } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 5adfd86b8..1c40f03df 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -387,7 +387,7 @@ struct WelcomeWindowView: View { VStack(alignment: .leading, spacing: 2) { Text(linked.connection.name) .lineLimit(1) - Text(verbatim: "\(linked.connection.host):\(linked.connection.port)") + Text(verbatim: connectionSubtitle(for: linked.connection)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -400,6 +400,17 @@ struct WelcomeWindowView: View { .listRowSeparator(.hidden) } + /// SQLite and DuckDB are file-based, so show the database path instead + /// of the meaningless `localhost:0`. + private func connectionSubtitle(for connection: ExportableConnection) -> String { + if connection.type == "SQLite" || connection.type == "DuckDB" { + return connection.database.isEmpty + ? connection.type + : (connection.database as NSString).abbreviatingWithTildeInPath + } + return "\(connection.host):\(connection.port)" + } + func primaryAction(for ids: Set) { guard !ids.isEmpty else { return } for connection in vm.connections where ids.contains(connection.id) { From c019e1ad9ab345f89cbb480fdc2a7defcaff82db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 18 May 2026 16:03:27 +0700 Subject: [PATCH 3/3] =?UTF-8?q?refactor(import):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20single=20db=20open,=20cancellation,=20DRY=20subtitl?= =?UTF-8?q?e,=20encryptor=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BeekeeperStudioImporter: open the SQLite database once per import and pass the handle to readSavedConnections and readConnectionFolders instead of opening it twice - BeekeeperStudioImporter: check Task.checkCancellation() in the per-row loop so a long import can be aborted - ExportableConnection: extract displaySubtitle so the SQLite/DuckDB file-path handling lives in one place; ConnectionImportPreviewList and WelcomeWindowView both use it now - BeekeeperEncryptor: add decryptDictionary typed wrapper, document why HMAC verification is skipped, and add a source-of-truth comment on the driver map - BeekeeperEncryptorTests: round-trip simple-encryptor payloads against the helper (string + dictionary), wrong-key rejection, malformed payload rejection --- .../ForeignApp/BeekeeperEncryptor.swift | 14 +- .../ForeignApp/BeekeeperStudioImporter.swift | 30 +++-- .../Models/Connection/ConnectionExport.swift | 12 ++ .../ConnectionImportPreviewList.swift | 15 +-- .../Views/Connection/WelcomeWindowView.swift | 13 +- .../ForeignApp/BeekeeperEncryptorTests.swift | 122 ++++++++++++++++++ 6 files changed, 165 insertions(+), 41 deletions(-) create mode 100644 TableProTests/Core/Services/ForeignApp/BeekeeperEncryptorTests.swift diff --git a/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift b/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift index 2e2debb92..e00514aa7 100644 --- a/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift +++ b/TablePro/Core/Services/Export/ForeignApp/BeekeeperEncryptor.swift @@ -33,6 +33,12 @@ enum BeekeeperEncryptor { /// Decrypts a `simple-encryptor` payload with the given raw key string /// and returns the JSON-decoded plaintext as a Swift value. Returns nil /// if the payload is malformed or decryption fails. + /// + /// HMAC verification is intentionally skipped: we read a file the user + /// owns, and tampered ciphertext will fail the downstream JSON decode + /// and surface as a nil return anyway. Skipping the HMAC keeps this + /// helper small and avoids re-implementing `simple-encryptor`'s + /// constant-time compare. static func decrypt(_ payload: String, key: String) -> Any? { guard payload.count > 96 else { return nil } let ivHexStart = payload.index(payload.startIndex, offsetBy: 64) @@ -56,12 +62,16 @@ enum BeekeeperEncryptor { ) } - /// Convenience wrapper for the common case where the plaintext is a - /// single string (e.g. a password). + /// Typed wrapper for the common single-string case (e.g. a password). static func decryptString(_ payload: String, key: String) -> String? { decrypt(payload, key: key) as? String } + /// Typed wrapper for the dictionary case (e.g. the `.key` file). + static func decryptDictionary(_ payload: String, key: String) -> [String: Any]? { + decrypt(payload, key: key) as? [String: Any] + } + // MARK: - Primitives private static func sha256(_ data: Data) -> Data { diff --git a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift index 1016193cf..6325d7389 100644 --- a/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift @@ -34,20 +34,24 @@ struct BeekeeperStudioImporter: ForeignAppImporter { private var keyFileURL: URL { dataDirectoryURL.appendingPathComponent(".key") } func connectionCount() -> Int { - (try? readSavedConnections().count) ?? 0 + guard let db = try? openDatabase() else { return 0 } + defer { sqlite3_close(db) } + return (try? readSavedConnections(db: db).count) ?? 0 } func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult { - let rows: [SavedConnectionRow] + let db: OpaquePointer? do { - rows = try readSavedConnections() + db = try openDatabase() } catch let error as ForeignAppImportError { throw error } catch { throw ForeignAppImportError.parseError(error.localizedDescription) } + defer { sqlite3_close(db) } - let folderMap = (try? readConnectionFolders()) ?? [:] + let rows = try readSavedConnections(db: db) + let folderMap = (try? readConnectionFolders(db: db)) ?? [:] let userKey = includePasswords ? loadUserEncryptionKey() : nil var exportableConnections: [ExportableConnection] = [] @@ -55,6 +59,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { var credentials: [String: ExportableCredentials] = [:] for row in rows { + try Task.checkCancellation() guard let type = Self.mapDriver(row.connectionType) else { Self.logger.warning("Skipping Beekeeper connection \(row.id) with unsupported driver \(row.connectionType ?? "", privacy: .public)") continue @@ -114,6 +119,11 @@ struct BeekeeperStudioImporter: ForeignAppImporter { // MARK: - Driver Mapping + /// Beekeeper's `connectionType` strings come from the `ConnectionType` + /// enum in + /// `beekeeper-studio/apps/studio/src/lib/db/types.ts`. Update this map + /// when Beekeeper adds a driver TablePro now supports. Unmapped drivers + /// are skipped with a warning at the call site. private static func mapDriver(_ raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } switch raw.lowercased() { @@ -224,7 +234,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { private func loadUserEncryptionKey() -> String? { guard let data = try? Data(contentsOf: keyFileURL), let payload = String(data: data, encoding: .utf8), - let decoded = BeekeeperEncryptor.decrypt(payload, key: BeekeeperEncryptor.defaultKey) as? [String: Any], + let decoded = BeekeeperEncryptor.decryptDictionary(payload, key: BeekeeperEncryptor.defaultKey), let key = decoded["encryptionKey"] as? String else { return nil } @@ -280,10 +290,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { let connectionFolderId: Int? } - private func readSavedConnections() throws -> [SavedConnectionRow] { - let db = try openDatabase() - defer { sqlite3_close(db) } - + private func readSavedConnections(db: OpaquePointer?) throws -> [SavedConnectionRow] { // Only personal workspace; cloud-synced rows have positive workspaceId. let sql = """ SELECT id, name, connectionType, host, port, username, defaultDatabase, password, @@ -339,10 +346,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter { return rows } - private func readConnectionFolders() throws -> [Int: String] { - let db = try openDatabase() - defer { sqlite3_close(db) } - + private func readConnectionFolders(db: OpaquePointer?) throws -> [Int: String] { var statement: OpaquePointer? guard sqlite3_prepare_v2(db, "SELECT id, name FROM connection_folder", -1, &statement, nil) == SQLITE_OK else { return [:] diff --git a/TablePro/Models/Connection/ConnectionExport.swift b/TablePro/Models/Connection/ConnectionExport.swift index ac2e90d02..f5d16b59c 100644 --- a/TablePro/Models/Connection/ConnectionExport.swift +++ b/TablePro/Models/Connection/ConnectionExport.swift @@ -70,6 +70,18 @@ struct ExportableConnection: Codable { startupCommands: startupCommands, localOnly: localOnly ) } + + /// One-line subtitle for connection rows. File-based databases + /// (SQLite, DuckDB) show the database path; everything else shows + /// `host:port`. + var displaySubtitle: String { + if type == "SQLite" || type == "DuckDB" { + return database.isEmpty + ? type + : (database as NSString).abbreviatingWithTildeInPath + } + return "\(host):\(port)" + } } // MARK: - SSH Config diff --git a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift index 2c694e9d9..7191f7b62 100644 --- a/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift +++ b/TablePro/Views/Connection/ImportFromApp/ConnectionImportPreviewList.swift @@ -57,7 +57,7 @@ struct ConnectionImportPreviewList: View { } } HStack(spacing: 0) { - Text(verbatim: subtitle(for: item.connection)) + Text(verbatim: item.connection.displaySubtitle) warningText(for: item.status) } .font(.subheadline) @@ -113,17 +113,4 @@ struct ConnectionImportPreviewList: View { } } - /// File-based databases (SQLite, DuckDB) don't have a meaningful - /// host/port. Show the database path instead, abbreviating the home - /// directory the same way Finder does. - private func subtitle(for connection: ExportableConnection) -> String { - switch connection.type { - case "SQLite", "DuckDB": - return connection.database.isEmpty - ? connection.type - : (connection.database as NSString).abbreviatingWithTildeInPath - default: - return "\(connection.host):\(connection.port)" - } - } } diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 1c40f03df..b9967ecb0 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -387,7 +387,7 @@ struct WelcomeWindowView: View { VStack(alignment: .leading, spacing: 2) { Text(linked.connection.name) .lineLimit(1) - Text(verbatim: connectionSubtitle(for: linked.connection)) + Text(verbatim: linked.connection.displaySubtitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -400,17 +400,6 @@ struct WelcomeWindowView: View { .listRowSeparator(.hidden) } - /// SQLite and DuckDB are file-based, so show the database path instead - /// of the meaningless `localhost:0`. - private func connectionSubtitle(for connection: ExportableConnection) -> String { - if connection.type == "SQLite" || connection.type == "DuckDB" { - return connection.database.isEmpty - ? connection.type - : (connection.database as NSString).abbreviatingWithTildeInPath - } - return "\(connection.host):\(connection.port)" - } - func primaryAction(for ids: Set) { guard !ids.isEmpty else { return } for connection in vm.connections where ids.contains(connection.id) { diff --git a/TableProTests/Core/Services/ForeignApp/BeekeeperEncryptorTests.swift b/TableProTests/Core/Services/ForeignApp/BeekeeperEncryptorTests.swift new file mode 100644 index 000000000..518dbb49a --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/BeekeeperEncryptorTests.swift @@ -0,0 +1,122 @@ +// +// BeekeeperEncryptorTests.swift +// TableProTests +// + +import CommonCrypto +import Foundation +import Testing +@testable import TablePro + +@Suite("BeekeeperEncryptor") +struct BeekeeperEncryptorTests { + @Test + func decryptsStringEncryptedInSimpleEncryptorFormat() throws { + let key = "user-key-1234567890" + let plaintext = "hunter2" + + let payload = try encryptForTesting(jsonValue: plaintext, key: key) + let decoded = BeekeeperEncryptor.decryptString(payload, key: key) + + #expect(decoded == plaintext) + } + + @Test + func decryptsDictionaryEncryptedInSimpleEncryptorFormat() throws { + let key = BeekeeperEncryptor.defaultKey + let plaintext: [String: String] = ["encryptionKey": "abc123"] + + let payload = try encryptForTesting(jsonValue: plaintext, key: key) + let decoded = BeekeeperEncryptor.decryptDictionary(payload, key: key) + + #expect(decoded?["encryptionKey"] as? String == "abc123") + } + + @Test + func returnsNilForMalformedPayload() { + #expect(BeekeeperEncryptor.decrypt("too-short", key: "any") == nil) + #expect(BeekeeperEncryptor.decrypt(String(repeating: "z", count: 200), key: "any") == nil) + } + + @Test + func returnsNilForWrongKey() throws { + let payload = try encryptForTesting(jsonValue: "secret", key: "correct-key") + #expect(BeekeeperEncryptor.decryptString(payload, key: "wrong-key") == nil) + } +} + +// MARK: - Helper: simple-encryptor wire format + +/// Produces a payload matching Node `simple-encryptor` (HMAC enabled). The +/// HMAC is generated but not verified by `BeekeeperEncryptor`, so any 64 +/// hex chars at the start would work; we still build a real one to keep the +/// fixture honest. +private func encryptForTesting(jsonValue: Any, key: String) throws -> String { + let json = try JSONSerialization.data(withJSONObject: jsonValue, options: [.fragmentsAllowed]) + let derivedKey = sha256(Data(key.utf8)) + var iv = Data(count: kCCBlockSizeAES128) + _ = iv.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, kCCBlockSizeAES128, $0.baseAddress!) } + + let cipher = try aes256CBCEncrypt(json, key: derivedKey, iv: iv) + let ivHex = iv.map { String(format: "%02x", $0) }.joined() + let cipherBase64 = cipher.base64EncodedString() + let hmacInput = (ivHex + cipherBase64).data(using: .utf8) ?? Data() + let hmacHex = hmacSHA256(hmacInput, key: derivedKey).map { String(format: "%02x", $0) }.joined() + return hmacHex + ivHex + cipherBase64 +} + +private func sha256(_ data: Data) -> Data { + var hash = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + hash.withUnsafeMutableBytes { hashBytes in + data.withUnsafeBytes { dataBytes in + _ = CC_SHA256(dataBytes.baseAddress, CC_LONG(data.count), hashBytes.bindMemory(to: UInt8.self).baseAddress) + } + } + return hash +} + +private func hmacSHA256(_ data: Data, key: Data) -> Data { + var mac = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + mac.withUnsafeMutableBytes { macBytes in + key.withUnsafeBytes { keyBytes in + data.withUnsafeBytes { dataBytes in + CCHmac( + CCHmacAlgorithm(kCCHmacAlgSHA256), + keyBytes.baseAddress, key.count, + dataBytes.baseAddress, data.count, + macBytes.baseAddress + ) + } + } + } + return mac +} + +private func aes256CBCEncrypt(_ plaintext: Data, key: Data, iv: Data) throws -> Data { + let bufferSize = plaintext.count + kCCBlockSizeAES128 + var buffer = Data(count: bufferSize) + var written = 0 + + let status = buffer.withUnsafeMutableBytes { bufferBytes -> CCCryptorStatus in + plaintext.withUnsafeBytes { plainBytes in + iv.withUnsafeBytes { ivBytes in + key.withUnsafeBytes { keyBytes in + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, kCCKeySizeAES256, + ivBytes.baseAddress, + plainBytes.baseAddress, plaintext.count, + bufferBytes.baseAddress, bufferSize, + &written + ) + } + } + } + } + guard status == kCCSuccess else { + throw NSError(domain: "BeekeeperEncryptorTests", code: Int(status)) + } + return buffer.prefix(written) +}