diff --git a/CHANGELOG.md b/CHANGELOG.md index ced712dc7..a9cfdd2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374) + ## [0.43.3] - 2026-05-22 ### Fixed diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift new file mode 100644 index 000000000..da233eb4e --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGrip/DataGripDataSourceParser.swift @@ -0,0 +1,170 @@ +// +// DataGripDataSourceParser.swift +// TablePro +// + +import Foundation + +struct DataGripDataSource { + let uuid: String + let name: String + let driverRef: String + let jdbcURL: String + let username: String + let groupName: String? + let ssh: DataGripSSHReference? + let ssl: DataGripSSLProperties? +} + +/// A single `` element. DataGrip splits one logical data source +/// across the shared `dataSources.xml` (driver, jdbc-url, group) and the +/// machine-local `dataSources.local.xml` (user name, ssh-properties, +/// ssl-properties). Fragments from both files merge by uuid before resolving. +struct DataGripDataSourceFragment { + let uuid: String + var name: String? + var driverRef: String? + var jdbcURL: String? + var username: String? + var groupName: String? + var ssh: DataGripSSHReference? + var ssl: DataGripSSLProperties? + + mutating func merge(_ other: DataGripDataSourceFragment) { + name = other.name ?? name + driverRef = other.driverRef ?? driverRef + jdbcURL = other.jdbcURL ?? jdbcURL + username = other.username ?? username + groupName = other.groupName ?? groupName + ssh = other.ssh ?? ssh + ssl = other.ssl ?? ssl + } + + func resolved() -> DataGripDataSource? { + guard let driverRef, !driverRef.isEmpty, + let jdbcURL, !jdbcURL.isEmpty else { return nil } + return DataGripDataSource( + uuid: uuid, + name: name ?? uuid, + driverRef: driverRef, + jdbcURL: jdbcURL, + username: username ?? "", + groupName: groupName, + ssh: ssh, + ssl: ssl + ) + } +} + +struct DataGripSSHReference { + let enabled: Bool + let configId: String? + let inlineHost: String? + let inlinePort: Int? + let inlineUser: String? +} + +struct DataGripSSLProperties { + let mode: String? + let caCertPath: String? + let clientCertPath: String? + let clientKeyPath: String? +} + +struct DataGripSSHConfig { + let id: String + let host: String + let port: Int? + let username: String + let authType: String? + let keyPath: String? +} + +enum DataGripDataSourceParser { + static func parseFragments(_ data: Data) -> [DataGripDataSourceFragment] { + guard let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//data-source") else { return [] } + + return nodes.compactMap { node in + guard let element = node as? XMLElement, let uuid = element.attr("uuid") else { return nil } + return parseFragment(element, uuid: uuid) + } + } + + static func parseSSHConfigs(_ data: Data) -> [String: DataGripSSHConfig] { + guard let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//sshConfig") else { return [:] } + + var result: [String: DataGripSSHConfig] = [:] + for node in nodes { + guard let element = node as? XMLElement, + let id = element.attr("id") else { continue } + let config = DataGripSSHConfig( + id: id, + host: element.attr("host") ?? "", + port: element.attr("port").flatMap { Int($0) }, + username: element.attr("username") ?? "", + authType: element.attr("authType"), + keyPath: element.attr("keyPath").map { JetBrainsPathMacros.expand($0) } + ) + result[id] = config + } + return result + } + + // MARK: - Private + + private static func parseFragment(_ element: XMLElement, uuid: String) -> DataGripDataSourceFragment { + var fragment = DataGripDataSourceFragment(uuid: uuid) + fragment.name = element.attr("name").flatMap { $0.isEmpty ? nil : $0 } + fragment.driverRef = element.childText("driver-ref") + fragment.jdbcURL = element.childText("jdbc-url").flatMap { $0.isEmpty ? nil : $0 } + fragment.username = element.childText("user-name").flatMap { $0.isEmpty ? nil : $0 } + fragment.groupName = element.attr("group-name").flatMap { $0.isEmpty ? nil : $0 } + fragment.ssh = parseSSHReference(element) + fragment.ssl = parseSSLProperties(element) + return fragment + } + + private static func parseSSHReference(_ element: XMLElement) -> DataGripSSHReference? { + guard let ssh = element.elements(forName: "ssh-properties").first else { return nil } + + let enabled = (ssh.childText("enabled") ?? ssh.attr("enabled")) == "true" + guard enabled else { return nil } + + let configId = ssh.childText("ssh-config-id") ?? ssh.attr("ssh-config-id") + return DataGripSSHReference( + enabled: true, + configId: configId.flatMap { $0.isEmpty ? nil : $0 }, + inlineHost: ssh.attr("host"), + inlinePort: ssh.attr("port").flatMap { Int($0) }, + inlineUser: ssh.attr("user") ?? ssh.attr("username") + ) + } + + private static func parseSSLProperties(_ element: XMLElement) -> DataGripSSLProperties? { + guard let ssl = element.elements(forName: "ssl-config").first, + ssl.childText("enabled") == "true" else { return nil } + + return DataGripSSLProperties( + mode: ssl.childText("mode"), + caCertPath: ssl.certPath("ca-cert"), + clientCertPath: ssl.certPath("client-cert"), + clientKeyPath: ssl.certPath("client-key") + ) + } +} + +private extension XMLElement { + func childText(_ name: String) -> String? { + elements(forName: name).first?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func attr(_ name: String) -> String? { + attribute(forName: name)?.stringValue + } + + func certPath(_ name: String) -> String? { + childText(name).flatMap { $0.isEmpty ? nil : JetBrainsPathMacros.expand($0) } + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift b/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift new file mode 100644 index 000000000..8a0bcc5fe --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGrip/JDBCConnectionString.swift @@ -0,0 +1,200 @@ +// +// JDBCConnectionString.swift +// TablePro +// + +import Foundation + +enum JDBCConnectionString { + struct Endpoint { + let host: String + let port: Int? + let database: String + } + + static func parse(url: String, subprotocol: String) -> Endpoint? { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("jdbc:") else { return nil } + let body = String(trimmed.dropFirst("jdbc:".count)) + + switch subprotocol.lowercased() { + case "sqlserver", "jtds": + return parseSQLServer(body) + case "oracle": + return parseOracle(body) + case "sqlite", "duckdb", "h2": + return parseFilePath(body, subprotocol: subprotocol) + case "bigquery": + return parseBigQuery(body) + default: + return parseAuthority(body) + } + } + + // MARK: - Authority Form + + /// jdbc:://[user[:pass]@]host[:port][/database][?params] + /// Covers MySQL, MariaDB, PostgreSQL, ClickHouse, MongoDB, Cassandra, Redis. + private static func parseAuthority(_ body: String) -> Endpoint? { + guard let schemeRange = body.range(of: "://") else { return nil } + var remainder = String(body[schemeRange.upperBound...]) + + remainder = stripQuery(from: remainder) + let pathSplit = splitOnce(remainder, separator: "/") + let authority = stripUserInfo(pathSplit.head) + let database = pathSplit.tail ?? "" + + let firstHost = authority.components(separatedBy: ",").first ?? authority + let (host, port) = parseHostPort(firstHost) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: database) + } + + // MARK: - SQL Server Form + + /// jdbc:sqlserver://host[\instance][:port][;prop=value;...] + /// jdbc:jtds:sqlserver://host:port/database + private static func parseSQLServer(_ body: String) -> Endpoint? { + var remainder = body + if remainder.lowercased().hasPrefix("jtds:") { + remainder = String(remainder.dropFirst("jtds:".count)) + } + if remainder.lowercased().hasPrefix("sqlserver:") { + remainder = String(remainder.dropFirst("sqlserver:".count)) + } + guard remainder.hasPrefix("//") else { return nil } + remainder = String(remainder.dropFirst(2)) + + let semicolonSplit = splitOnce(remainder, separator: ";") + let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") + + let beforeProps = semicolonSplit.head + let slashSplit = splitOnce(beforeProps, separator: "/") + var authority = slashSplit.head + var database = slashSplit.tail ?? "" + + if let backslash = authority.firstIndex(of: "\\") { + authority = String(authority[.. Endpoint? { + guard let atIndex = body.firstIndex(of: "@") else { return nil } + var descriptor = String(body[body.index(after: atIndex)...]) + descriptor = stripQuery(from: descriptor) + + if descriptor.hasPrefix("//") { + descriptor = String(descriptor.dropFirst(2)) + let slashSplit = splitOnce(descriptor, separator: "/") + let (host, port) = parseHostPort(slashSplit.head) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + } + + let parts = descriptor.components(separatedBy: ":") + guard parts.count >= 2, !parts[0].isEmpty else { + let slashSplit = splitOnce(descriptor, separator: "/") + let (host, port) = parseHostPort(slashSplit.head) + guard !host.isEmpty else { return nil } + return Endpoint(host: host, port: port, database: slashSplit.tail ?? "") + } + + let host = parts[0] + let portAndRest = parts[1] + let slashSplit = splitOnce(portAndRest, separator: "/") + let port = Int(slashSplit.head) + + if let serviceName = slashSplit.tail { + return Endpoint(host: host, port: port, database: serviceName) + } + let sid = parts.count >= 3 ? parts[2] : "" + return Endpoint(host: host, port: port, database: sid) + } + + // MARK: - File Form + + /// jdbc:sqlite:/path/to/file.db, jdbc:duckdb:/path/to/file.duckdb + private static func parseFilePath(_ body: String, subprotocol: String) -> Endpoint? { + guard body.lowercased().hasPrefix(subprotocol.lowercased() + ":") else { + return Endpoint(host: "", port: nil, database: stripQuery(from: body)) + } + var path = String(body.dropFirst(subprotocol.count + 1)) + path = stripQuery(from: path) + return Endpoint(host: "", port: nil, database: path) + } + + // MARK: - BigQuery Form + + /// jdbc:bigquery://https://www.googleapis.com/bigquery/v2;ProjectId=my-project;... + private static func parseBigQuery(_ body: String) -> Endpoint? { + guard body.hasPrefix("//") else { return nil } + let remainder = String(body.dropFirst(2)) + let semicolonSplit = splitOnce(remainder, separator: ";") + let properties = parseSemicolonProperties(semicolonSplit.tail ?? "") + let project = properties["projectid"] ?? properties["project"] ?? "" + return Endpoint(host: semicolonSplit.head, port: nil, database: project) + } + + // MARK: - Helpers + + private static func parseHostPort(_ authority: String) -> (host: String, port: Int?) { + if authority.hasPrefix("[") { + guard let closing = authority.firstIndex(of: "]") else { + return (authority, nil) + } + let host = String(authority[authority.index(after: authority.startIndex).. String { + guard let atIndex = authority.lastIndex(of: "@") else { return authority } + return String(authority[authority.index(after: atIndex)...]) + } + + private static func stripQuery(from value: String) -> String { + if let question = value.firstIndex(of: "?") { + return String(value[.. (head: String, tail: String?) { + guard let index = value.firstIndex(of: separator) else { return (value, nil) } + return (String(value[.. [String: String] { + var result: [String: String] = [:] + for pair in value.components(separatedBy: ";") where !pair.isEmpty { + let kv = splitOnce(pair, separator: "=") + guard let rawValue = kv.tail else { continue } + result[kv.head.lowercased().trimmingCharacters(in: .whitespaces)] = rawValue + } + return result + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift new file mode 100644 index 000000000..c0af9c53e --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift @@ -0,0 +1,416 @@ +// +// DataGripImporter.swift +// TablePro +// + +import AppKit +import Foundation +import os +import TableProPluginKit + +struct DataGripImporter: ForeignAppImporter { + private static let logger = Logger(subsystem: "com.TablePro", category: "DataGripImporter") + + let id = "datagrip" + let displayName = "DataGrip" + let symbolName = "cylinder.split.1x2" + let appBundleIdentifier = "com.jetbrains.datagrip" + + /// Root holding versioned IDE config dirs (`DataGrip2024.3`, ...). Injectable for tests. + var jetBrainsRoot: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/JetBrains") + + private struct Location { + let dataSourcesURL: URL + let localURL: URL? + let configDir: URL + } + + func isAvailable() -> Bool { + installedAppURL() != nil || !locations().isEmpty + } + + func connectionCount() -> Int { + var seen = Set() + for location in locations() { + for source in dataSources(at: location) { + seen.insert(source.uuid) + } + } + return seen.count + } + + func importConnections(includePasswords: Bool) throws -> ForeignAppImportResult { + let locations = locations() + guard !locations.isEmpty else { + throw ForeignAppImportError.fileNotFound(displayName) + } + + var seenUUIDs = Set() + var exportableConnections: [ExportableConnection] = [] + var groupNames = Set() + var credentials: [String: ExportableCredentials] = [:] + var credentialsAborted = false + var sshConfigsByDir: [URL: [String: DataGripSSHConfig]] = [:] + + for location in locations { + let sshConfigs = sshConfigsByDir[location.configDir] ?? { + let loaded = loadSSHConfigs(configDir: location.configDir) + sshConfigsByDir[location.configDir] = loaded + return loaded + }() + let credentialStore = includePasswords ? JetBrainsCredentialStore(configDir: location.configDir) : nil + + for source in dataSources(at: location) { + guard seenUUIDs.insert(source.uuid).inserted, + let connection = makeConnection(source, sshConfigs: sshConfigs) else { continue } + + let index = exportableConnections.count + exportableConnections.append(connection) + if let groupName = connection.groupName { + groupNames.insert(groupName) + } + + if let store = credentialStore, !credentialsAborted { + let collected = collectCredentials(for: source, sshConfigs: sshConfigs, store: store) + if let resolved = collected.credentials { + credentials[String(index)] = resolved + } + credentialsAborted = collected.aborted + } + } + } + + guard !exportableConnections.isEmpty else { + throw ForeignAppImportError.noConnectionsFound + } + + let groups: [ExportableGroup]? = groupNames.isEmpty ? nil : groupNames.map { + ExportableGroup(name: $0, color: nil) + } + + let envelope = ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "DataGrip Import", + connections: exportableConnections, + groups: groups, + tags: nil, + credentials: credentials.isEmpty ? nil : credentials + ) + + return ForeignAppImportResult( + envelope: envelope, + sourceName: displayName, + credentialsAborted: credentialsAborted + ) + } + + // MARK: - Credentials + + private struct CollectedCredentials { + var credentials: ExportableCredentials? + var aborted: Bool + } + + /// Reads the data-source password plus, when the connection tunnels over an + /// SSH config, its saved secret: a key passphrase for key auth or a password + /// otherwise. The SSH secret is keyed by `: `. `aborted` + /// is set when the user denies Keychain access so the caller stops prompting. + private func collectCredentials( + for source: DataGripDataSource, + sshConfigs: [String: DataGripSSHConfig], + store: JetBrainsCredentialStore + ) -> CollectedCredentials { + var password: String? + var sshPassword: String? + var keyPassphrase: String? + var aborted = false + + switch store.password(forDataSourceUUID: source.uuid) { + case .found(let value): password = value + case .cancelled: aborted = true + case .notFound: break + } + + if !aborted, let configId = source.ssh?.configId, let config = sshConfigs[configId] { + let host = config.host + let port = config.port ?? 22 + let usesKey = usesKeyAuthentication(authType: config.authType, keyPath: config.keyPath ?? "") + switch usesKey + ? store.sshKeyPassphrase(host: host, port: port, configId: configId) + : store.sshPassword(host: host, port: port, configId: configId) { + case .found(let value): + if usesKey { keyPassphrase = value } else { sshPassword = value } + case .cancelled: aborted = true + case .notFound: break + } + } + + guard password != nil || sshPassword != nil || keyPassphrase != nil else { + return CollectedCredentials(credentials: nil, aborted: aborted) + } + return CollectedCredentials( + credentials: ExportableCredentials( + password: password, + sshPassword: sshPassword, + keyPassphrase: keyPassphrase, + totpSecret: nil, + pluginSecureFields: nil + ), + aborted: aborted + ) + } + + // MARK: - Discovery + + private func locations() -> [Location] { + var result: [Location] = [] + for configDir in dataGripConfigDirs() { + appendLocation(directory: configDir.appendingPathComponent("options"), configDir: configDir, into: &result) + + let projectsDir = configDir.appendingPathComponent("projects") + if let projects = try? FileManager.default.contentsOfDirectory( + at: projectsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) { + for project in projects { + appendLocation(directory: project.appendingPathComponent(".idea"), configDir: configDir, into: &result) + } + } + + for projectPath in recentProjectPaths(configDir: configDir) { + let ideaDir = URL(fileURLWithPath: projectPath).appendingPathComponent(".idea") + appendLocation(directory: ideaDir, configDir: configDir, into: &result) + } + } + return result + } + + private func dataGripConfigDirs() -> [URL] { + guard let dirs = try? FileManager.default.contentsOfDirectory( + at: jetBrainsRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + return dirs + .filter { $0.lastPathComponent.hasPrefix("DataGrip") } + .sorted { $0.lastPathComponent > $1.lastPathComponent } + } + + private func appendLocation(directory: URL, configDir: URL, into result: inout [Location]) { + let dataSources = directory.appendingPathComponent("dataSources.xml") + guard FileManager.default.fileExists(atPath: dataSources.path) else { return } + + let local = directory.appendingPathComponent("dataSources.local.xml") + result.append(Location( + dataSourcesURL: dataSources, + localURL: FileManager.default.fileExists(atPath: local.path) ? local : nil, + configDir: configDir + )) + } + + private func recentProjectPaths(configDir: URL) -> [String] { + let url = configDir.appendingPathComponent("options/recentProjects.xml") + guard let data = try? Data(contentsOf: url), + let document = try? XMLDocument(data: data), + let nodes = try? document.nodes(forXPath: "//entry/@key") else { return [] } + + return nodes.compactMap { node in + node.stringValue.map { JetBrainsPathMacros.expand($0) } + } + } + + /// DataGrip stores SSH connection details once per IDE under + /// `options/sshConfigs.xml`, keyed by id and referenced from each data + /// source's ``. + private func loadSSHConfigs(configDir: URL) -> [String: DataGripSSHConfig] { + let url = configDir.appendingPathComponent("options/sshConfigs.xml") + guard let data = try? Data(contentsOf: url) else { return [:] } + return DataGripDataSourceParser.parseSSHConfigs(data) + } + + /// Merges the shared `dataSources.xml` with the machine-local + /// `dataSources.local.xml`. The shared file carries the driver and JDBC URL; + /// the local file carries the user name, SSH and SSL properties. Fragments + /// join by uuid with the local file overriding the fields it provides. + private func dataSources(at location: Location) -> [DataGripDataSource] { + var fragments: [String: DataGripDataSourceFragment] = [:] + var order: [String] = [] + + for url in [location.dataSourcesURL, location.localURL].compactMap({ $0 }) { + guard let data = try? Data(contentsOf: url) else { continue } + for fragment in DataGripDataSourceParser.parseFragments(data) { + if fragments[fragment.uuid] == nil { + order.append(fragment.uuid) + fragments[fragment.uuid] = fragment + } else { + fragments[fragment.uuid]?.merge(fragment) + } + } + } + return order.compactMap { fragments[$0]?.resolved() } + } + + // MARK: - Mapping + + private func makeConnection( + _ source: DataGripDataSource, + sshConfigs: [String: DataGripSSHConfig] + ) -> ExportableConnection? { + let subprotocol = jdbcSubprotocol(source.jdbcURL) + let type = mapDriverRef(source.driverRef, subprotocol: subprotocol) + let endpoint = JDBCConnectionString.parse(url: source.jdbcURL, subprotocol: subprotocol) + + let host = endpoint?.host ?? "localhost" + let database = endpoint?.database ?? "" + let port = endpoint?.port ?? defaultPort(for: type) + + return ExportableConnection( + name: source.name, + host: host, + port: port, + database: database, + username: source.username, + type: type, + sshConfig: makeSSHConfig(source.ssh, sshConfigs: sshConfigs), + sslConfig: makeSSLConfig(source.ssl), + color: nil, + tagName: nil, + groupName: source.groupName, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + } + + private func makeSSHConfig( + _ reference: DataGripSSHReference?, + sshConfigs: [String: DataGripSSHConfig] + ) -> ExportableSSHConfig? { + guard let reference, reference.enabled else { return nil } + + let config = reference.configId.flatMap { sshConfigs[$0] } + let host = config?.host ?? reference.inlineHost ?? "" + guard !host.isEmpty else { return nil } + + let keyPath = config?.keyPath ?? "" + let usesKey = usesKeyAuthentication(authType: config?.authType, keyPath: keyPath) + + return ExportableSSHConfig( + enabled: true, + host: host, + port: config?.port ?? reference.inlinePort, + username: config?.username ?? reference.inlineUser ?? "", + authMethod: usesKey ? "Private Key" : "Password", + privateKeyPath: usesKey ? ForeignAppPathHelper.resolveKeyPath(keyPath) : "", + agentSocketPath: "", + jumpHosts: nil, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + } + + /// DataGrip omits `authType` when the connection relies on the OpenSSH + /// config, so a present key path is the reliable signal for key auth. + private func usesKeyAuthentication(authType: String?, keyPath: String) -> Bool { + switch (authType ?? "").uppercased() { + case "KEY_PAIR", "PUBLIC_KEY", "OPEN_SSH": + return true + case "PASSWORD": + return false + default: + return !keyPath.isEmpty + } + } + + private func makeSSLConfig(_ ssl: DataGripSSLProperties?) -> ExportableSSLConfig? { + guard let ssl else { return nil } + + let mode: String + switch (ssl.mode ?? "").lowercased() { + case "require", "required": mode = SSLMode.required.rawValue + case "verify_ca", "verify-ca": mode = SSLMode.verifyCa.rawValue + case "verify_full", "verify-full": mode = SSLMode.verifyIdentity.rawValue + default: mode = SSLMode.preferred.rawValue + } + + return ExportableSSLConfig( + mode: mode, + caCertificatePath: ssl.caCertPath, + clientCertificatePath: ssl.clientCertPath, + clientKeyPath: ssl.clientKeyPath + ) + } + + private func jdbcSubprotocol(_ url: String) -> String { + guard url.lowercased().hasPrefix("jdbc:") else { return "" } + var subprotocol = "" + for character in url.dropFirst("jdbc:".count) { + if character == ":" || character == "/" { break } + subprotocol.append(character) + } + return subprotocol + } + + private func mapDriverRef(_ driverRef: String, subprotocol: String) -> String { + let token = driverRef.lowercased().split(separator: ".").first.map(String.init) ?? driverRef.lowercased() + switch token { + case "mysql": return "MySQL" + case "mariadb": return "MariaDB" + case "postgresql", "postgres": return "PostgreSQL" + case "sqlite": return "SQLite" + case "sqlserver", "mssql", "jtds": return "SQL Server" + case "oracle": return "Oracle" + case "mongo", "mongodb": return "MongoDB" + case "redis": return "Redis" + case "clickhouse": return "ClickHouse" + case "cassandra": return "Cassandra" + case "duckdb": return "DuckDB" + case "bigquery": return "BigQuery" + case "cockroach", "cockroachdb": return "CockroachDB" + case "redshift": return "Redshift" + default: return mapSubprotocol(subprotocol, fallback: driverRef) + } + } + + private func mapSubprotocol(_ subprotocol: String, fallback: String) -> String { + switch subprotocol.lowercased() { + case "mysql": return "MySQL" + case "mariadb": return "MariaDB" + case "postgresql": return "PostgreSQL" + case "sqlite": return "SQLite" + case "sqlserver", "jtds": return "SQL Server" + case "oracle": return "Oracle" + case "mongodb": return "MongoDB" + case "redis": return "Redis" + case "clickhouse": return "ClickHouse" + case "cassandra": return "Cassandra" + case "duckdb": return "DuckDB" + case "bigquery": return "BigQuery" + default: return fallback + } + } + + private func defaultPort(for type: String) -> Int { + switch type { + case "MySQL", "MariaDB": return 3_306 + case "PostgreSQL", "CockroachDB", "Redshift": return 5_432 + case "MongoDB": return 27_017 + case "Redis": return 6_379 + case "SQL Server": return 1_433 + case "Oracle": return 1_521 + case "ClickHouse": return 8_123 + case "Cassandra": return 9_042 + default: return 0 + } + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift index 545b5726f..418417371 100644 --- a/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift +++ b/TablePro/Core/Services/Export/ForeignApp/ForeignAppImporter.swift @@ -80,6 +80,7 @@ enum ForeignAppImporterRegistry { TablePlusImporter(), SequelAceImporter(), DBeaverImporter(), + DataGripImporter(), BeekeeperStudioImporter() ] } diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift new file mode 100644 index 000000000..3e48d0621 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsCredentialStore.swift @@ -0,0 +1,211 @@ +// +// JetBrainsCredentialStore.swift +// TablePro +// + +import CommonCrypto +import Foundation +import os +import Security + +/// Resolves JetBrains credentials. The IDE names each secret with +/// `generateServiceName(subsystem, key)`, formatted +/// `IntelliJ Platform \u{2014} `: +/// - DB password: subsystem `DB`, key ``. +/// - SSH tunnel password: subsystem `SshConfigPassword`, key `: `. +/// - SSH key passphrase: subsystem `SshConfigPassphrase`, same key shape. +/// +/// SSH secrets only exist once a connection authenticated and saved them. On macOS +/// the secret lives in the native Keychain; only the "In KeePass" mode writes the +/// encrypted `c.kdbx`, so the Keychain is tried first and the KDBX is a fallback. +final class JetBrainsCredentialStore { + enum Lookup { + case found(String) + case notFound + case cancelled + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "JetBrainsCredentialStore") + + /// ASCII bytes of "Proxy Config Sec", the hardcoded AES-128 key the IDE uses + /// for the BUILT_IN encryption of the KDBX main key in `c.pwd`. + private static let builtInKey: [UInt8] = Array("Proxy Config Sec".utf8) + + private let configDir: URL + private var kdbxEntriesByTitle: [String: KdbxEntry]? + private var kdbxLoaded = false + private var storeLocked = false + + init(configDir: URL) { + self.configDir = configDir + } + + static func serviceName(forDataSourceUUID uuid: String) -> String { + "IntelliJ Platform DB \u{2014} \(uuid)" + } + + static func sshPasswordServiceName(host: String, port: Int, configId: String) -> String { + "IntelliJ Platform SshConfigPassword \u{2014} \(host):\(port) \(configId)" + } + + static func sshPassphraseServiceName(host: String, port: Int, configId: String) -> String { + "IntelliJ Platform SshConfigPassphrase \u{2014} \(host):\(port) \(configId)" + } + + func password(forDataSourceUUID uuid: String) -> Lookup { + secret(service: Self.serviceName(forDataSourceUUID: uuid)) + } + + func sshPassword(host: String, port: Int, configId: String) -> Lookup { + secret(service: Self.sshPasswordServiceName(host: host, port: port, configId: configId)) + } + + func sshKeyPassphrase(host: String, port: Int, configId: String) -> Lookup { + secret(service: Self.sshPassphraseServiceName(host: host, port: port, configId: configId)) + } + + private func secret(service: String) -> Lookup { + switch readKeychain(service: service) { + case .found(let value): return .found(value) + case .cancelled: return .cancelled + case .notFound: break + } + + if let entry = loadKdbxEntries()?[service], !entry.password.isEmpty { + return .found(entry.password) + } + return storeLocked ? .cancelled : .notFound + } + + // MARK: - Keychain + + private func readKeychain(service: String) -> Lookup { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data, let value = String(data: data, encoding: .utf8) else { + return .notFound + } + return .found(value) + case errSecItemNotFound: + return .notFound + default: + Self.logger.debug("Keychain read denied or cancelled for \(service): \(status)") + return .cancelled + } + } + + // MARK: - KDBX + + private func loadKdbxEntries() -> [String: KdbxEntry]? { + if kdbxLoaded { return kdbxEntriesByTitle } + kdbxLoaded = true + + let kdbxURL = configDir.appendingPathComponent("c.kdbx") + guard FileManager.default.fileExists(atPath: kdbxURL.path), + let fileData = try? Data(contentsOf: kdbxURL), + let mainKey = loadMainKey() else { return nil } + + do { + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + kdbxEntriesByTitle = Dictionary(entries.map { ($0.title, $0) }, uniquingKeysWith: { first, _ in first }) + } catch { + Self.logger.warning("Failed to read c.kdbx: \(error.localizedDescription)") + } + return kdbxEntriesByTitle + } + + private func loadMainKey() -> [UInt8]? { + for fileName in ["c.pwd", "pdb.pwd"] { + let url = configDir.appendingPathComponent(fileName) + guard let text = try? String(contentsOf: url, encoding: .utf8), + let parsed = parseMainKeyFile(text) else { continue } + guard parsed.encryption == "BUILT_IN" else { + Self.logger.warning("Unsupported c.pwd encryption: \(parsed.encryption)") + storeLocked = true + continue + } + if let key = decryptBuiltIn(parsed.value) { + return key + } + } + return nil + } + + private func parseMainKeyFile(_ text: String) -> (encryption: String, value: [UInt8])? { + var encryption = "BUILT_IN" + var base64Parts: [String] = [] + var capturingValue = false + + for line in text.components(separatedBy: .newlines) { + if capturingValue { + if let first = line.first, first == " " || first == "\t" { + base64Parts.append(line) + continue + } + capturingValue = false + } + if let range = line.range(of: "encryption:") { + encryption = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + } else if let range = line.range(of: "value:") { + let rest = String(line[range.upperBound...]) + .replacingOccurrences(of: "!!binary", with: "") + .trimmingCharacters(in: .whitespaces) + if rest.isEmpty || rest == "|" || rest == "|-" || rest == ">" { + capturingValue = true + } else { + base64Parts.append(rest) + } + } + } + + let alphabet = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=") + let base64 = String(base64Parts.joined().filter { alphabet.contains($0) }) + guard let data = Data(base64Encoded: base64) else { return nil } + return (encryption, [UInt8](data)) + } + + private func decryptBuiltIn(_ blob: [UInt8]) -> [UInt8]? { + guard blob.count > 4 else { return nil } + let ivLength = Int(bigEndianUInt32(blob, 0)) + guard ivLength == kCCBlockSizeAES128, blob.count > 4 + ivLength else { return nil } + let iv = Array(blob[4..<4 + ivLength]) + let ciphertext = Array(blob[(4 + ivLength)...]) + return aesCBCDecrypt(ciphertext, key: Self.builtInKey, iv: iv) + } + + private func aesCBCDecrypt(_ ciphertext: [UInt8], key: [UInt8], iv: [UInt8]) -> [UInt8]? { + var output = [UInt8](repeating: 0, count: ciphertext.count + kCCBlockSizeAES128) + var outputLength = 0 + let status = CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + ciphertext, + ciphertext.count, + &output, + output.count, + &outputLength + ) + guard status == kCCSuccess else { return nil } + return Array(output.prefix(outputLength)) + } + + private func bigEndianUInt32(_ data: [UInt8], _ index: Int) -> UInt32 { + (UInt32(data[index]) << 24) + | (UInt32(data[index + 1]) << 16) + | (UInt32(data[index + 2]) << 8) + | UInt32(data[index + 3]) + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift new file mode 100644 index 000000000..4462ea56b --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/JetBrainsPathMacros.swift @@ -0,0 +1,14 @@ +// +// JetBrainsPathMacros.swift +// TablePro +// + +import Foundation + +/// Expands the path macros JetBrains IDEs write into their config XML, such as +/// `$USER_HOME$` in SSH key paths and recent-project entries. +enum JetBrainsPathMacros { + static func expand(_ path: String) -> String { + path.replacingOccurrences(of: "$USER_HOME$", with: NSHomeDirectory()) + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift new file mode 100644 index 000000000..d636d74a0 --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxDatabase.swift @@ -0,0 +1,349 @@ +// +// KdbxDatabase.swift +// TablePro +// + +import CommonCrypto +import Compression +import Foundation +import os + +enum KdbxError: Error { + case malformedHeader + case unsupportedVersion + case wrongKey + case corruptedData +} + +struct KdbxEntry { + let title: String + let userName: String + let password: String +} + +/// Reader for the KDBX 3.1 file (`c.kdbx`) that JetBrains IDEs write with their +/// own `com.intellij.credentialStore` implementation. Decryption is AES-KDF +/// (iterated AES-256-ECB) plus AES-256-CBC, so CommonCrypto is sufficient. +enum KdbxDatabase { + private static let logger = Logger(subsystem: "com.TablePro", category: "KdbxDatabase") + + private static let sig1: UInt32 = 0x9AA2_D903 + private static let sig2: UInt32 = 0xB54B_FB67 + + private struct Header { + var mainSeed: [UInt8] = [] + var transformSeed: [UInt8] = [] + var transformRounds: UInt64 = 0 + var encryptionIV: [UInt8] = [] + var protectedStreamKey: [UInt8] = [] + var streamStartBytes: [UInt8] = [] + var innerStreamID: UInt32 = 0 + var compression: UInt32 = 0 + } + + static func read(fileData: Data, mainKey: [UInt8]) throws -> [KdbxEntry] { + let bytes = [UInt8](fileData) + let (header, payloadOffset) = try parseHeader(bytes) + + let finalKey = try deriveFinalKey(header: header, mainKey: mainKey) + let payload = Array(bytes[payloadOffset...]) + + guard let plaintext = aesCBCDecrypt(payload, key: finalKey, iv: header.encryptionIV) else { + throw KdbxError.corruptedData + } + guard plaintext.count >= 32, Array(plaintext.prefix(32)) == header.streamStartBytes else { + throw KdbxError.wrongKey + } + + guard let deframed = dehashBlocks(Array(plaintext.dropFirst(32))) else { + throw KdbxError.corruptedData + } + let xmlBytes = header.compression == 1 ? (gunzip(deframed) ?? []) : deframed + guard !xmlBytes.isEmpty else { throw KdbxError.corruptedData } + + let cipher = makeInnerCipher(id: header.innerStreamID, streamKey: header.protectedStreamKey) + return parseEntries(Data(xmlBytes), cipher: cipher) + } + + // MARK: - Header + + private static func parseHeader(_ data: [UInt8]) throws -> (Header, Int) { + guard data.count > 12, + readUInt32LE(data, 0) == sig1, + readUInt32LE(data, 4) == sig2 else { + throw KdbxError.malformedHeader + } + let version = readUInt32LE(data, 8) + guard (version & 0xFFFF_0000) <= 0x0003_0000 else { throw KdbxError.unsupportedVersion } + + var header = Header() + var pos = 12 + while pos + 3 <= data.count { + let fieldType = data[pos] + let length = Int(readUInt16LE(data, pos + 1)) + pos += 3 + guard pos + length <= data.count else { throw KdbxError.malformedHeader } + let field = Array(data[pos.. [UInt8] { + let composite = sha256(sha256(mainKey)) + guard let transformed = aesKdf( + input: composite, + seed: header.transformSeed, + rounds: header.transformRounds + ) else { + throw KdbxError.corruptedData + } + return sha256(header.mainSeed + sha256(transformed)) + } + + private static func aesKdf(input: [UInt8], seed: [UInt8], rounds: UInt64) -> [UInt8]? { + guard input.count == 32, seed.count == kCCKeySizeAES256 else { return nil } + + var cryptor: CCCryptorRef? + let createStatus = CCCryptorCreate( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + seed, + seed.count, + nil, + &cryptor + ) + guard createStatus == kCCSuccess, let cryptor else { return nil } + defer { CCCryptorRelease(cryptor) } + + var current = input + var next = [UInt8](repeating: 0, count: 32) + for _ in 0.. [UInt8]? { + var result: [UInt8] = [] + var pos = 0 + while pos + 40 <= data.count { + let hashStart = pos + 4 + let size = Int(readUInt32LE(data, pos + 36)) + pos += 40 + if size == 0 { return result } + guard pos + size <= data.count else { return nil } + let block = Array(data[pos.. KdbxInnerStreamCipher? { + switch id { + case 2: + return Salsa20Cipher(key: sha256(streamKey), nonce: Salsa20Cipher.keePassNonce) + case 3: + let digest = sha512(streamKey) + return ChaCha20Cipher(key: Array(digest[0..<32]), nonce: Array(digest[32..<44])) + default: + logger.warning("Unsupported KDBX inner stream id \(id); passwords will be skipped") + return nil + } + } + + // MARK: - XML + + private static func parseEntries(_ xml: Data, cipher: KdbxInnerStreamCipher?) -> [KdbxEntry] { + guard let document = try? XMLDocument(data: xml) else { return [] } + + var decryptedValues: [ObjectIdentifier: String] = [:] + if var activeCipher = cipher, let valueNodes = try? document.nodes(forXPath: "//Value") { + for node in valueNodes { + guard let element = node as? XMLElement, + (element.attribute(forName: "Protected")?.stringValue ?? "").lowercased() == "true", + let encoded = element.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines), + let data = Data(base64Encoded: encoded) else { continue } + let plain = activeCipher.process([UInt8](data)) + decryptedValues[ObjectIdentifier(element)] = String(bytes: plain, encoding: .utf8) ?? "" + } + } + + guard let entryNodes = try? document.nodes(forXPath: "//Entry") else { return [] } + var entries: [KdbxEntry] = [] + for node in entryNodes { + guard let entry = node as? XMLElement else { continue } + entries.append(parseEntry(entry, decryptedValues: decryptedValues)) + } + return entries + } + + private static func parseEntry(_ entry: XMLElement, decryptedValues: [ObjectIdentifier: String]) -> KdbxEntry { + var title = "" + var userName = "" + var password = "" + for stringElement in entry.elements(forName: "String") { + let key = stringElement.elements(forName: "Key").first?.stringValue ?? "" + guard let valueElement = stringElement.elements(forName: "Value").first else { continue } + switch key { + case "Title": title = valueElement.stringValue ?? "" + case "UserName": userName = valueElement.stringValue ?? "" + case "Password": + password = decryptedValues[ObjectIdentifier(valueElement)] ?? (valueElement.stringValue ?? "") + default: break + } + } + return KdbxEntry(title: title, userName: userName, password: password) + } + + // MARK: - Crypto Primitives + + private static func aesCBCDecrypt(_ ciphertext: [UInt8], key: [UInt8], iv: [UInt8]) -> [UInt8]? { + guard !ciphertext.isEmpty, iv.count == kCCBlockSizeAES128 else { return nil } + var output = [UInt8](repeating: 0, count: ciphertext.count + kCCBlockSizeAES128) + var outputLength = 0 + let status = CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + ciphertext, + ciphertext.count, + &output, + output.count, + &outputLength + ) + guard status == kCCSuccess else { return nil } + return Array(output.prefix(outputLength)) + } + + private static func sha256(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256(data, CC_LONG(data.count), &hash) + return hash + } + + private static func sha512(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CC_SHA512(data, CC_LONG(data.count), &hash) + return hash + } + + // MARK: - GZIP + + private static func gunzip(_ data: [UInt8]) -> [UInt8]? { + guard data.count > 18, data[0] == 0x1f, data[1] == 0x8b, data[2] == 0x08 else { return nil } + let flags = data[3] + var offset = 10 + if flags & 0x04 != 0 { + guard offset + 2 <= data.count else { return nil } + let extraLength = Int(readUInt16LE(data, offset)) + offset += 2 + extraLength + } + if flags & 0x08 != 0 { + while offset < data.count, data[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x10 != 0 { + while offset < data.count, data[offset] != 0 { offset += 1 } + offset += 1 + } + if flags & 0x02 != 0 { + offset += 2 + } + guard offset < data.count - 8 else { return nil } + return inflateRawDeflate(Array(data[offset..<(data.count - 8)])) + } + + private static func inflateRawDeflate(_ input: [UInt8]) -> [UInt8]? { + let bufferSize = 65_536 + let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { destination.deallocate() } + + var stream = compression_stream( + dst_ptr: destination, + dst_size: bufferSize, + src_ptr: UnsafePointer(destination), + src_size: 0, + state: nil + ) + guard compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else { + return nil + } + defer { compression_stream_destroy(&stream) } + + return input.withUnsafeBufferPointer { source -> [UInt8]? in + guard let base = source.baseAddress else { return nil } + stream.src_ptr = base + stream.src_size = source.count + stream.dst_ptr = destination + stream.dst_size = bufferSize + + var output: [UInt8] = [] + while true { + let status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) + switch status { + case COMPRESSION_STATUS_OK: + if stream.dst_size == 0 { + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize)) + stream.dst_ptr = destination + stream.dst_size = bufferSize + } + case COMPRESSION_STATUS_END: + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize - stream.dst_size)) + return output + default: + return nil + } + } + } + } + + // MARK: - Little-Endian Readers + + private static func readUInt16LE(_ data: [UInt8], _ index: Int) -> UInt16 { + UInt16(data[index]) | (UInt16(data[index + 1]) << 8) + } + + private static func readUInt32LE(_ data: [UInt8], _ index: Int) -> UInt32 { + UInt32(data[index]) + | (UInt32(data[index + 1]) << 8) + | (UInt32(data[index + 2]) << 16) + | (UInt32(data[index + 3]) << 24) + } + + private static func readUInt64LE(_ data: [UInt8], _ index: Int) -> UInt64 { + var value: UInt64 = 0 + for offset in 0..<8 { + value |= UInt64(data[index + offset]) << (8 * offset) + } + return value + } +} diff --git a/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift new file mode 100644 index 000000000..e19a1b4ea --- /dev/null +++ b/TablePro/Core/Services/Export/ForeignApp/JetBrains/KdbxInnerStreamCipher.swift @@ -0,0 +1,180 @@ +// +// KdbxInnerStreamCipher.swift +// TablePro +// + +import Foundation + +protocol KdbxInnerStreamCipher { + mutating func process(_ data: [UInt8]) -> [UInt8] +} + +/// IETF ChaCha20 (RFC 8439). KeePass derives key and nonce from SHA-512 of the +/// protected-stream key: key = digest[0..<32], nonce = digest[32..<44]. +struct ChaCha20Cipher: KdbxInnerStreamCipher { + private var state: [UInt32] + private var keyStream: [UInt8] = [] + private var offset = 0 + + init(key: [UInt8], nonce: [UInt8]) { + var initial = [UInt32](repeating: 0, count: 16) + initial[0] = 0x6170_7865 + initial[1] = 0x3320_646e + initial[2] = 0x7962_2d32 + initial[3] = 0x6b20_6574 + for index in 0..<8 { + initial[4 + index] = Self.load32(key, index * 4) + } + initial[12] = 0 + for index in 0..<3 { + initial[13 + index] = Self.load32(nonce, index * 4) + } + state = initial + } + + mutating func process(_ data: [UInt8]) -> [UInt8] { + var output = [UInt8](repeating: 0, count: data.count) + for index in 0..= keyStream.count { + keyStream = nextBlock() + offset = 0 + } + output[index] = data[index] ^ keyStream[offset] + offset += 1 + } + return output + } + + private mutating func nextBlock() -> [UInt8] { + var working = state + for _ in 0..<10 { + Self.quarterRound(&working, 0, 4, 8, 12) + Self.quarterRound(&working, 1, 5, 9, 13) + Self.quarterRound(&working, 2, 6, 10, 14) + Self.quarterRound(&working, 3, 7, 11, 15) + Self.quarterRound(&working, 0, 5, 10, 15) + Self.quarterRound(&working, 1, 6, 11, 12) + Self.quarterRound(&working, 2, 7, 8, 13) + Self.quarterRound(&working, 3, 4, 9, 14) + } + var block = [UInt8](repeating: 0, count: 64) + for index in 0..<16 { + Self.store32(working[index] &+ state[index], &block, index * 4) + } + state[12] = state[12] &+ 1 + return block + } + + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = rotl(s[d], 16) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = rotl(s[b], 12) + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = rotl(s[d], 8) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = rotl(s[b], 7) + } + + private static func rotl(_ value: UInt32, _ count: UInt32) -> UInt32 { + (value << count) | (value >> (32 - count)) + } + + private static func load32(_ bytes: [UInt8], _ index: Int) -> UInt32 { + UInt32(bytes[index]) + | (UInt32(bytes[index + 1]) << 8) + | (UInt32(bytes[index + 2]) << 16) + | (UInt32(bytes[index + 3]) << 24) + } + + private static func store32(_ value: UInt32, _ bytes: inout [UInt8], _ index: Int) { + bytes[index] = UInt8(value & 0xff) + bytes[index + 1] = UInt8((value >> 8) & 0xff) + bytes[index + 2] = UInt8((value >> 16) & 0xff) + bytes[index + 3] = UInt8((value >> 24) & 0xff) + } +} + +/// Salsa20 with the fixed KeePass nonce 0xE830094B97205D2A and key = SHA-256 of +/// the protected-stream key. Used by KDBX files written before the ChaCha20 default. +struct Salsa20Cipher: KdbxInnerStreamCipher { + static let keePassNonce: [UInt8] = [0xe8, 0x30, 0x09, 0x4b, 0x97, 0x20, 0x5d, 0x2a] + + private var state: [UInt32] + private var keyStream: [UInt8] = [] + private var offset = 0 + + init(key: [UInt8], nonce: [UInt8]) { + var initial = [UInt32](repeating: 0, count: 16) + initial[0] = 0x6170_7865 + initial[5] = 0x3320_646e + initial[10] = 0x7962_2d32 + initial[15] = 0x6b20_6574 + for index in 0..<4 { + initial[1 + index] = Self.load32(key, index * 4) + initial[11 + index] = Self.load32(key, 16 + index * 4) + } + initial[6] = Self.load32(nonce, 0) + initial[7] = Self.load32(nonce, 4) + initial[8] = 0 + initial[9] = 0 + state = initial + } + + mutating func process(_ data: [UInt8]) -> [UInt8] { + var output = [UInt8](repeating: 0, count: data.count) + for index in 0..= keyStream.count { + keyStream = nextBlock() + offset = 0 + } + output[index] = data[index] ^ keyStream[offset] + offset += 1 + } + return output + } + + private mutating func nextBlock() -> [UInt8] { + var working = state + for _ in 0..<10 { + Self.quarterRound(&working, 0, 4, 8, 12) + Self.quarterRound(&working, 5, 9, 13, 1) + Self.quarterRound(&working, 10, 14, 2, 6) + Self.quarterRound(&working, 15, 3, 7, 11) + Self.quarterRound(&working, 0, 1, 2, 3) + Self.quarterRound(&working, 5, 6, 7, 4) + Self.quarterRound(&working, 10, 11, 8, 9) + Self.quarterRound(&working, 15, 12, 13, 14) + } + var block = [UInt8](repeating: 0, count: 64) + for index in 0..<16 { + Self.store32(working[index] &+ state[index], &block, index * 4) + } + state[8] = state[8] &+ 1 + if state[8] == 0 { + state[9] = state[9] &+ 1 + } + return block + } + + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + s[b] ^= rotl(s[a] &+ s[d], 7) + s[c] ^= rotl(s[b] &+ s[a], 9) + s[d] ^= rotl(s[c] &+ s[b], 13) + s[a] ^= rotl(s[d] &+ s[c], 18) + } + + private static func rotl(_ value: UInt32, _ count: UInt32) -> UInt32 { + (value << count) | (value >> (32 - count)) + } + + private static func load32(_ bytes: [UInt8], _ index: Int) -> UInt32 { + UInt32(bytes[index]) + | (UInt32(bytes[index + 1]) << 8) + | (UInt32(bytes[index + 2]) << 16) + | (UInt32(bytes[index + 3]) << 24) + } + + private static func store32(_ value: UInt32, _ bytes: inout [UInt8], _ index: Int) { + bytes[index] = UInt8(value & 0xff) + bytes[index + 1] = UInt8((value >> 8) & 0xff) + bytes[index + 2] = UInt8((value >> 16) & 0xff) + bytes[index + 3] = UInt8((value >> 24) & 0xff) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift new file mode 100644 index 000000000..d8302a997 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/DataGripImporterTests.swift @@ -0,0 +1,327 @@ +// +// DataGripImporterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("DataGripImporter", .serialized) +struct DataGripImporterTests { + private let root: URL + private let optionsDir: URL + private var importer: DataGripImporter + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("DataGripImporterTests-\(UUID().uuidString)") + optionsDir = root.appendingPathComponent("DataGrip2025.1/options") + try FileManager.default.createDirectory(at: optionsDir, withIntermediateDirectories: true) + + var imp = DataGripImporter() + imp.jetBrainsRoot = root + importer = imp + } + + // MARK: - Fixtures + + private func writeDataSources(_ elements: [String]) throws { + let xml = """ + + + + \(elements.joined(separator: "\n")) + + + """ + try xml.write(to: optionsDir.appendingPathComponent("dataSources.xml"), atomically: true, encoding: .utf8) + } + + private func writeLocalDataSources(_ elements: [String]) throws { + let xml = """ + + + + \(elements.joined(separator: "\n")) + + + """ + try xml.write(to: optionsDir.appendingPathComponent("dataSources.local.xml"), atomically: true, encoding: .utf8) + } + + private func writeSSHConfigs(_ configs: [String]) throws { + let xml = """ + + + + \(configs.joined(separator: "\n")) + + + + """ + try xml.write(to: optionsDir.appendingPathComponent("sshConfigs.xml"), atomically: true, encoding: .utf8) + } + + private func source( + uuid: String, + name: String, + driverRef: String, + jdbcURL: String, + userName: String = "", + group: String? = nil, + extra: String = "" + ) -> String { + let groupAttr = group.map { " group-name=\"\($0)\"" } ?? "" + let userElement = userName.isEmpty ? "" : "\(userName)" + return """ + + \(driverRef) + \(jdbcURL) + \(userElement) + \(extra) + + """ + } + + private func localSource(uuid: String, name: String = "", userName: String = "", extra: String = "") -> String { + let nameAttr = name.isEmpty ? "" : " name=\"\(name)\"" + let userElement = userName.isEmpty ? "" : "\(userName)" + return """ + + \(userElement) + \(extra) + + """ + } + + // MARK: - Discovery + + @Test("connectionCount counts unique data sources") + func connectionCount() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "2", name: "B", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/b") + ]) + #expect(importer.connectionCount() == 2) + } + + @Test("import throws when no DataGrip data found") + func noData() { + #expect(throws: ForeignAppImportError.self) { + try importer.importConnections(includePasswords: false) + } + } + + // MARK: - Mapping + + @Test("maps driver-ref to database types") + func driverMapping() throws { + try writeDataSources([ + source(uuid: "1", name: "my", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "2", name: "pg", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/b"), + source(uuid: "3", name: "ms", driverRef: "sqlserver.ms", jdbcURL: "jdbc:sqlserver://h:1433;databaseName=c"), + source(uuid: "4", name: "or", driverRef: "oracle", jdbcURL: "jdbc:oracle:thin:@h:1521:ORCL"), + source(uuid: "5", name: "lt", driverRef: "sqlite.xerial", jdbcURL: "jdbc:sqlite:/tmp/x.db") + ]) + + let result = try importer.importConnections(includePasswords: false) + let types = Dictionary(uniqueKeysWithValues: result.envelope.connections.map { ($0.name, $0.type) }) + + #expect(types["my"] == "MySQL") + #expect(types["pg"] == "PostgreSQL") + #expect(types["ms"] == "SQL Server") + #expect(types["or"] == "Oracle") + #expect(types["lt"] == "SQLite") + } + + @Test("parses host, port and database from jdbc url") + func endpointParsing() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://db.example.com:3307/shop", userName: "root") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.host == "db.example.com") + #expect(connection.port == 3_307) + #expect(connection.database == "shop") + #expect(connection.username == "root") + } + + @Test("uses default port when jdbc url omits it") + func defaultPort() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://localhost/app") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.port == 5_432) + } + + @Test("SQLite stores file path as database") + func sqlitePath() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "sqlite.xerial", jdbcURL: "jdbc:sqlite:/Users/me/app.db") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.type == "SQLite") + #expect(connection.database == "/Users/me/app.db") + } + + // MARK: - SSH + + @Test("joins SSH config from local file, infers key auth, expands $USER_HOME$") + func sshJoin() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( + uuid: "1", + userName: "appuser", + extra: "trueSSH1" + ) + ]) + try writeSSHConfigs([ + """ + + """ + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.username == "appuser") + let ssh = try #require(connection.sshConfig) + #expect(ssh.host == "bastion.example.com") + #expect(ssh.port == 2_222) + #expect(ssh.username == "deploy") + #expect(ssh.authMethod == "Private Key") + #expect(ssh.privateKeyPath == "\(NSHomeDirectory())/.ssh/id_ed25519") + } + + @Test("respects explicit password auth even with a key path") + func sshPasswordAuth() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( + uuid: "1", + extra: "trueSSH1" + ) + ]) + try writeSSHConfigs([ + "" + ]) + + let ssh = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first?.sshConfig) + #expect(ssh.authMethod == "Password") + #expect(ssh.privateKeyPath == "") + } + + @Test("no SSH when properties disabled in local file") + func sshDisabled() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", extra: "false") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.sshConfig == nil) + } + + @Test("imports SSH password from c.kdbx end to end") + func sshPasswordImported() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + try writeLocalDataSources([ + localSource( + uuid: "1", + extra: "trueSSH1" + ) + ]) + try writeSSHConfigs([ + "" + ]) + + let configDir = optionsDir.deletingLastPathComponent() + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.sshPasswordServiceName(host: "localhost", port: 22, configId: "SSH1") + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "", password: "ssh-pw") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let result = try importer.importConnections(includePasswords: true) + let credentials = try #require(result.envelope.credentials?["0"]) + #expect(credentials.sshPassword == "ssh-pw") + } + + @Test("merges user-name from local file when shared file omits it") + func usernameFromLocalFile() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/db") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", userName: "postgres") + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + #expect(connection.username == "postgres") + } + + // MARK: - SSL + + @Test("parses SSL config from local file and expands $USER_HOME$") + func sslParsing() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "postgresql", jdbcURL: "jdbc:postgresql://h:5432/a") + ]) + try writeLocalDataSources([ + localSource(uuid: "1", extra: """ + + $USER_HOME$/certs/ca.pem + $USER_HOME$/certs/client.crt + $USER_HOME$/certs/client.key + true + VERIFY_FULL + + """) + ]) + + let connection = try #require(try importer.importConnections(includePasswords: false).envelope.connections.first) + let ssl = try #require(connection.sslConfig) + #expect(ssl.mode == "Verify Identity") + #expect(ssl.caCertificatePath == "\(NSHomeDirectory())/certs/ca.pem") + #expect(ssl.clientCertificatePath == "\(NSHomeDirectory())/certs/client.crt") + #expect(ssl.clientKeyPath == "\(NSHomeDirectory())/certs/client.key") + } + + // MARK: - Groups & Dedup + + @Test("group-name attribute becomes a group") + func groups() throws { + try writeDataSources([ + source(uuid: "1", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a", group: "Production") + ]) + + let result = try importer.importConnections(includePasswords: false) + #expect(result.envelope.connections.first?.groupName == "Production") + #expect(result.envelope.groups?.contains { $0.name == "Production" } == true) + } + + @Test("deduplicates data sources by uuid") + func dedup() throws { + try writeDataSources([ + source(uuid: "dup", name: "A", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a"), + source(uuid: "dup", name: "A copy", driverRef: "mysql.8", jdbcURL: "jdbc:mysql://h:3306/a") + ]) + + let result = try importer.importConnections(includePasswords: false) + #expect(result.envelope.connections.count == 1) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift b/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift new file mode 100644 index 000000000..86c59b267 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/JDBCConnectionStringTests.swift @@ -0,0 +1,165 @@ +// +// JDBCConnectionStringTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("JDBCConnectionString") +struct JDBCConnectionStringTests { + @Test("MySQL with port and database") + func mysql() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://db.example.com:3307/shop", subprotocol: "mysql") + #expect(endpoint?.host == "db.example.com") + #expect(endpoint?.port == 3_307) + #expect(endpoint?.database == "shop") + } + + @Test("PostgreSQL strips query parameters") + func postgresWithParams() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:postgresql://localhost:5432/app?sslmode=require&user=x", + subprotocol: "postgresql" + ) + #expect(endpoint?.host == "localhost") + #expect(endpoint?.port == 5_432) + #expect(endpoint?.database == "app") + } + + @Test("MySQL without explicit port returns nil port") + func mysqlNoPort() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://localhost/app", subprotocol: "mysql") + #expect(endpoint?.host == "localhost") + #expect(endpoint?.port == nil) + #expect(endpoint?.database == "app") + } + + @Test("Authority strips user info") + func userInfo() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:mysql://root:pw@host:3306/db", subprotocol: "mysql") + #expect(endpoint?.host == "host") + #expect(endpoint?.port == 3_306) + } + + @Test("IPv6 host in brackets") + func ipv6() { + let endpoint = JDBCConnectionString.parse(url: "jdbc:postgresql://[::1]:5432/db", subprotocol: "postgresql") + #expect(endpoint?.host == "::1") + #expect(endpoint?.port == 5_432) + #expect(endpoint?.database == "db") + } + + @Test("SQL Server uses semicolon properties for database") + func sqlServerSemicolon() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlserver://sql.example.com:1433;databaseName=Sales;encrypt=true", + subprotocol: "sqlserver" + ) + #expect(endpoint?.host == "sql.example.com") + #expect(endpoint?.port == 1_433) + #expect(endpoint?.database == "Sales") + } + + @Test("SQL Server strips named instance") + func sqlServerInstance() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlserver://host\\SQLEXPRESS;databaseName=db", + subprotocol: "sqlserver" + ) + #expect(endpoint?.host == "host") + #expect(endpoint?.database == "db") + } + + @Test("jTDS SQL Server path form") + func jtds() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:jtds:sqlserver://host:1433/mydb", + subprotocol: "jtds" + ) + #expect(endpoint?.host == "host") + #expect(endpoint?.port == 1_433) + #expect(endpoint?.database == "mydb") + } + + @Test("Oracle SID form") + func oracleSID() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@orahost:1521:ORCL", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "ORCL") + } + + @Test("Oracle service name form with //") + func oracleService() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@//orahost:1521/PRODSVC", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "PRODSVC") + } + + @Test("Oracle service name form without //") + func oracleServiceNoSlashes() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:oracle:thin:@orahost:1521/PRODSVC", + subprotocol: "oracle" + ) + #expect(endpoint?.host == "orahost") + #expect(endpoint?.port == 1_521) + #expect(endpoint?.database == "PRODSVC") + } + + @Test("SQLite file path") + func sqlite() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:sqlite:/Users/me/data/app.db", + subprotocol: "sqlite" + ) + #expect(endpoint?.host == "") + #expect(endpoint?.port == nil) + #expect(endpoint?.database == "/Users/me/data/app.db") + } + + @Test("DuckDB file path") + func duckdb() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:duckdb:/tmp/analytics.duckdb", + subprotocol: "duckdb" + ) + #expect(endpoint?.database == "/tmp/analytics.duckdb") + } + + @Test("ClickHouse authority form") + func clickhouse() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:clickhouse://ch.example.com:8123/metrics", + subprotocol: "clickhouse" + ) + #expect(endpoint?.host == "ch.example.com") + #expect(endpoint?.port == 8_123) + #expect(endpoint?.database == "metrics") + } + + @Test("MongoDB with authSource query") + func mongo() { + let endpoint = JDBCConnectionString.parse( + url: "jdbc:mongodb://mongo:27017/app?authSource=admin", + subprotocol: "mongodb" + ) + #expect(endpoint?.host == "mongo") + #expect(endpoint?.port == 27_017) + #expect(endpoint?.database == "app") + } + + @Test("Non-jdbc url returns nil") + func notJdbc() { + #expect(JDBCConnectionString.parse(url: "mysql://x/y", subprotocol: "mysql") == nil) + } +} diff --git a/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift new file mode 100644 index 000000000..10c694dc5 --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/JetBrainsCredentialStoreTests.swift @@ -0,0 +1,94 @@ +// +// JetBrainsCredentialStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("JetBrainsCredentialStore", .serialized) +struct JetBrainsCredentialStoreTests { + private let configDir: URL + + init() throws { + configDir = FileManager.default.temporaryDirectory + .appendingPathComponent("JetBrainsCredentialStoreTests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + } + + @Test("service name uses em-dash separator") + func serviceNameFormat() { + let name = JetBrainsCredentialStore.serviceName(forDataSourceUUID: "abc-123") + #expect(name == "IntelliJ Platform DB \u{2014} abc-123") + } + + @Test("reads password from c.kdbx via c.pwd main key") + func keePassFallback() throws { + let uuid = "a1b2c3" + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.serviceName(forDataSourceUUID: uuid) + + let kdbx = KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "u", password: "kdbx-secret") + try kdbx.write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .found(let password) = store.password(forDataSourceUUID: uuid) else { + Issue.record("Expected password to be found in KDBX") + return + } + #expect(password == "kdbx-secret") + } + + @Test("missing files return notFound") + func notFound() { + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .notFound = store.password(forDataSourceUUID: "missing") else { + Issue.record("Expected notFound when no keychain item and no c.kdbx") + return + } + } + + @Test("password-protected store reports locked instead of notFound") + func lockedStoreReportsCancelled() throws { + let mainKey = KdbxTestFixture.randomBytes(64) + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: "x", userName: "u", password: "p") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try "isAutoGenerated: false\nvalue: !!binary AAAA\nencryption: MASTER_KEY\n" + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .cancelled = store.password(forDataSourceUUID: "any") else { + Issue.record("Expected cancelled when c.pwd is protected by a master password") + return + } + } + + @Test("ssh password service name matches DataGrip's keychain format") + func sshPasswordServiceName() { + #expect(JetBrainsCredentialStore.sshPasswordServiceName(host: "localhost", port: 22, configId: "cfg") + == "IntelliJ Platform SshConfigPassword \u{2014} localhost:22 cfg") + #expect(JetBrainsCredentialStore.sshPassphraseServiceName(host: "h", port: 2_222, configId: "cfg") + == "IntelliJ Platform SshConfigPassphrase \u{2014} h:2222 cfg") + } + + @Test("reads ssh password from c.kdbx") + func sshPasswordFromKdbx() throws { + let mainKey = KdbxTestFixture.randomBytes(64) + let service = JetBrainsCredentialStore.sshPasswordServiceName(host: "h", port: 2_222, configId: "cfg") + + try KdbxTestFixture.makeKdbx(mainKey: mainKey, title: service, userName: "", password: "ssh-secret") + .write(to: configDir.appendingPathComponent("c.kdbx")) + try KdbxTestFixture.makeMainKeyFile(mainKey: mainKey) + .write(to: configDir.appendingPathComponent("c.pwd"), atomically: true, encoding: .utf8) + + let store = JetBrainsCredentialStore(configDir: configDir) + guard case .found(let password) = store.sshPassword(host: "h", port: 2_222, configId: "cfg") else { + Issue.record("Expected SSH password to be found in KDBX") + return + } + #expect(password == "ssh-secret") + } +} diff --git a/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift new file mode 100644 index 000000000..7106c386b --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/KdbxDatabaseTests.swift @@ -0,0 +1,70 @@ +// +// KdbxDatabaseTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("KdbxDatabase") +struct KdbxDatabaseTests { + @Test("reads entry and decrypts ChaCha20-protected password") + func roundTrip() throws { + let mainKey: [UInt8] = Array("super-secret-main-key".utf8) + let title = "IntelliJ Platform DB \u{2014} a1b2c3" + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: title, + userName: "dbuser", + password: "p@ssw0rd!" + ) + + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + let entry = entries.first { $0.title == title } + + #expect(entry != nil) + #expect(entry?.userName == "dbuser") + #expect(entry?.password == "p@ssw0rd!") + } + + @Test("reads gzip-compressed KDBX payload") + func compressedRoundTrip() throws { + let mainKey: [UInt8] = Array("compressed-main-key".utf8) + let title = "IntelliJ Platform DB \u{2014} gz" + let fileData = KdbxTestFixture.makeKdbx( + mainKey: mainKey, + title: title, + userName: "dbuser", + password: "p@ssw0rd!", + compressed: true + ) + + let entries = try KdbxDatabase.read(fileData: fileData, mainKey: mainKey) + let entry = entries.first { $0.title == title } + + #expect(entry?.userName == "dbuser") + #expect(entry?.password == "p@ssw0rd!") + } + + @Test("wrong main key is rejected by stream-start check") + func wrongKeyThrows() { + let fileData = KdbxTestFixture.makeKdbx( + mainKey: Array("correct".utf8), + title: "t", + userName: "u", + password: "p" + ) + + #expect(throws: (any Error).self) { + _ = try KdbxDatabase.read(fileData: fileData, mainKey: Array("incorrect".utf8)) + } + } + + @Test("malformed signature throws") + func malformedThrows() { + #expect(throws: (any Error).self) { + _ = try KdbxDatabase.read(fileData: Data([0x00, 0x01, 0x02, 0x03]), mainKey: []) + } + } +} diff --git a/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift b/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift new file mode 100644 index 000000000..a87d8972a --- /dev/null +++ b/TableProTests/Core/Services/ForeignApp/KdbxInnerStreamCipherTests.swift @@ -0,0 +1,64 @@ +// +// KdbxInnerStreamCipherTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ChaCha20Cipher") +struct ChaCha20CipherTests { + /// RFC 8439 A.1 Test Vector #1: key = 0, nonce = 0, counter starts at 0. + @Test("RFC 8439 keystream block 0") + func keystreamBlockZero() { + var cipher = ChaCha20Cipher(key: [UInt8](repeating: 0, count: 32), nonce: [UInt8](repeating: 0, count: 12)) + let keystream = cipher.process([UInt8](repeating: 0, count: 64)) + + let expected = hex(""" + 76b8e0ada0f13d90405d6ae55386bd28bdd219b8a08ded1aa836efcc8b770dc7 + da41597c5157488d7724e03fb8d84a376a43b8f41518a11cc387b669b2ee6586 + """) + #expect(keystream == expected) + } + + /// RFC 8439 A.1 Test Vector #2: same key/nonce, counter 1 -> second block. + @Test("RFC 8439 keystream blocks 0 and 1") + func keystreamTwoBlocks() { + var cipher = ChaCha20Cipher(key: [UInt8](repeating: 0, count: 32), nonce: [UInt8](repeating: 0, count: 12)) + let keystream = cipher.process([UInt8](repeating: 0, count: 128)) + + let secondBlock = hex(""" + 9f07e7be5551387a98ba977c732d080dcb0f29a048e3656912c6533e32ee7aed + 29b721769ce64e43d57133b074d839d531ed1f28510afb45ace10a1f4b794d6f + """) + #expect(Array(keystream[64..<128]) == secondBlock) + } + + @Test("Process is XOR involution") + func roundTrip() { + let key = (0..<32).map { UInt8($0) } + let nonce = (0..<12).map { UInt8($0) } + let plaintext: [UInt8] = Array("the quick brown fox".utf8) + + var encryptCipher = ChaCha20Cipher(key: key, nonce: nonce) + let ciphertext = encryptCipher.process(plaintext) + + var decryptCipher = ChaCha20Cipher(key: key, nonce: nonce) + #expect(decryptCipher.process(ciphertext) == plaintext) + } + + private func hex(_ string: String) -> [UInt8] { + let cleaned = string.filter(\.isHexDigit) + var bytes: [UInt8] = [] + var index = cleaned.startIndex + while index < cleaned.endIndex { + let next = cleaned.index(index, offsetBy: 2) + if let byte = UInt8(cleaned[index.. Data { + let mainSeed = randomBytes(32) + let transformSeed = randomBytes(32) + let encryptionIV = randomBytes(16) + let protectedStreamKey = randomBytes(32) + let streamStartBytes = randomBytes(32) + + let digest = sha512(protectedStreamKey) + var cipher = ChaCha20Cipher(key: Array(digest[0..<32]), nonce: Array(digest[32..<44])) + let protectedPassword = Data(cipher.process(Array(password.utf8))).base64EncodedString() + + let xml = """ + + IntelliJ Platform + + Title\(title) + UserName\(userName) + Password\(protectedPassword) + + """ + let payload = compressed ? gzip(Array(xml.utf8)) : Array(xml.utf8) + + var framed: [UInt8] = [] + framed += le32(0) + framed += sha256(payload) + framed += le32(UInt32(payload.count)) + framed += payload + framed += le32(1) + framed += [UInt8](repeating: 0, count: 32) + framed += le32(0) + + let plaintext = streamStartBytes + framed + let composite = sha256(sha256(mainKey)) + let finalKey = sha256(mainSeed + sha256(aesKdf(composite, seed: transformSeed, rounds: rounds))) + let ciphertext = aesCBCEncrypt(plaintext, key: finalKey, iv: encryptionIV) + + var header: [UInt8] = [] + header += le32(0x9AA2_D903) + header += le32(0xB54B_FB67) + header += le32(0x0003_0001) + appendField(&header, 3, le32(compressed ? 1 : 0)) + appendField(&header, 4, mainSeed) + appendField(&header, 5, transformSeed) + appendField(&header, 6, le64(rounds)) + appendField(&header, 7, encryptionIV) + appendField(&header, 8, protectedStreamKey) + appendField(&header, 9, streamStartBytes) + appendField(&header, 10, le32(3)) + appendField(&header, 0, [0x0d, 0x0a, 0x0d, 0x0a]) + + return Data(header + ciphertext) + } + + /// Encodes `mainKey` the way `c.pwd` stores it: BUILT_IN AES-128-CBC under the + /// hardcoded "Proxy Config Sec" key, with a big-endian IV-length prefix. + static func makeMainKeyFile(mainKey: [UInt8]) -> String { + let iv = randomBytes(16) + let ciphertext = aesCBCEncrypt(mainKey, key: Array("Proxy Config Sec".utf8), iv: iv) + var blob = beBytes(UInt32(16)) + blob += iv + blob += ciphertext + let base64 = Data(blob).base64EncodedString() + return "isAutoGenerated: true\nvalue: !!binary \(base64)\nencryption: BUILT_IN\n" + } + + // MARK: - Crypto Helpers + + static func aesKdf(_ input: [UInt8], seed: [UInt8], rounds: UInt64) -> [UInt8] { + var cryptor: CCCryptorRef? + CCCryptorCreate( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionECBMode), + seed, + seed.count, + nil, + &cryptor + ) + guard let cryptor else { return input } + defer { CCCryptorRelease(cryptor) } + + var current = input + var next = [UInt8](repeating: 0, count: 32) + for _ in 0.. [UInt8] { + var output = [UInt8](repeating: 0, count: plaintext.count + kCCBlockSizeAES128) + var outputLength = 0 + CCCrypt( + CCOperation(kCCEncrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + key, + key.count, + iv, + plaintext, + plaintext.count, + &output, + output.count, + &outputLength + ) + return Array(output.prefix(outputLength)) + } + + static func sha256(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + CC_SHA256(data, CC_LONG(data.count), &hash) + return hash + } + + static func sha512(_ data: [UInt8]) -> [UInt8] { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CC_SHA512(data, CC_LONG(data.count), &hash) + return hash + } + + static func randomBytes(_ count: Int) -> [UInt8] { + var bytes = [UInt8](repeating: 0, count: count) + _ = SecRandomCopyBytes(kSecRandomDefault, count, &bytes) + return bytes + } + + // MARK: - Encoding Helpers + + private static func appendField(_ header: inout [UInt8], _ type: UInt8, _ data: [UInt8]) { + header.append(type) + header += le16(UInt16(data.count)) + header += data + } + + static func le16(_ value: UInt16) -> [UInt8] { + [UInt8(value & 0xff), UInt8((value >> 8) & 0xff)] + } + + static func le32(_ value: UInt32) -> [UInt8] { + (0..<4).map { UInt8((value >> (8 * $0)) & 0xff) } + } + + static func le64(_ value: UInt64) -> [UInt8] { + (0..<8).map { UInt8((value >> (8 * UInt64($0))) & 0xff) } + } + + static func beBytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff) + ] + } + + // MARK: - GZIP + + private static func gzip(_ data: [UInt8]) -> [UInt8] { + var output: [UInt8] = [0x1f, 0x8b, 0x08, 0x00, 0, 0, 0, 0, 0x00, 0xff] + output += rawDeflate(data) + output += le32(crc32(data)) + output += le32(UInt32(data.count & 0xffff_ffff)) + return output + } + + private static func rawDeflate(_ input: [UInt8]) -> [UInt8] { + let bufferSize = 65_536 + let destination = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { destination.deallocate() } + + var stream = compression_stream(dst_ptr: destination, dst_size: bufferSize, src_ptr: destination, src_size: 0, state: nil) + guard compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, COMPRESSION_ZLIB) == COMPRESSION_STATUS_OK else { + return input + } + defer { compression_stream_destroy(&stream) } + + return input.withUnsafeBufferPointer { source -> [UInt8] in + guard let base = source.baseAddress else { return input } + stream.src_ptr = base + stream.src_size = source.count + stream.dst_ptr = destination + stream.dst_size = bufferSize + + var output: [UInt8] = [] + while true { + switch compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue)) { + case COMPRESSION_STATUS_OK: + if stream.dst_size == 0 { + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize)) + stream.dst_ptr = destination + stream.dst_size = bufferSize + } + case COMPRESSION_STATUS_END: + output.append(contentsOf: UnsafeBufferPointer(start: destination, count: bufferSize - stream.dst_size)) + return output + default: + return input + } + } + } + } + + private static func crc32(_ data: [UInt8]) -> UInt32 { + var crc: UInt32 = 0xffff_ffff + for byte in data { + crc ^= UInt32(byte) + for _ in 0..<8 { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xedb8_8320 : crc >> 1 + } + } + return crc ^ 0xffff_ffff + } +} diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 9e017f330..2d608f1e9 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -58,7 +58,7 @@ Duplicates are unchecked. Check to import, then pick **As Copy**, **Replace**, o ## Import from Other Apps -Bring your connections over from TablePlus, Sequel Ace, or DBeaver. Passwords can be imported too. The source app doesn't need to be running. +Bring your connections over from TablePlus, Sequel Ace, DBeaver, or DataGrip. Passwords can be imported too. The source app doesn't need to be running. 1. **File** > **Import from Other App...** 2. Pick the source app and click **Continue**. @@ -84,6 +84,9 @@ Groups and folders carry over. | TablePlus | MySQL, PostgreSQL, MongoDB, SQLite, Redis, and more | From Keychain | | Sequel Ace | MySQL | From Keychain | | DBeaver | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | Decrypted from config file | +| DataGrip | MySQL, PostgreSQL, SQLite, SQL Server, Oracle, and more | From Keychain or `c.kdbx` | + +DataGrip stores connections per project. TablePro imports the connections from the projects DataGrip is tracking (its recent projects), along with their SSH tunnel and SSL settings, and the SSH tunnel password when DataGrip saved one. If a connection is missing, open its project in DataGrip once so it returns to the recent list. Connections protected by a DataGrip master password can't be read. ## Share via Link