Skip to content

Commit ea49e48

Browse files
authored
fix(plugin-cassandra): apply client certificate and key passphrase to SSL connections (#1520)
* fix(plugin-cassandra): apply client certificate and key passphrase to SSL connections * fix(plugin-cassandra): distinguish a malformed client key from an encrypted one
1 parent d9f49d4 commit ea49e48

25 files changed

Lines changed: 350 additions & 42 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
- Opening a table (Return or double-click in the sidebar) moves keyboard focus into the data grid so you can navigate cells with the arrow keys. Arrowing the sidebar still previews tables without taking focus. (#1490)
4949
- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs.
5050
- Opening a table on a connection with many tables no longer stalls for several seconds while autocomplete and table metadata load. Background schema introspection now runs on separate connections instead of waiting behind, or blocking, the query that fills the grid. (#1483)
51+
- Cassandra SSL connections that use a client certificate now have a Key Passphrase field for an encrypted private key, and report a clear "key is encrypted" or "passphrase is incorrect" message instead of a generic handshake failure. The passphrase is stored in the Keychain. (#1487)
5152

5253
## [0.46.0] - 2026-05-28
5354

Plugins/CassandraDriverPlugin/CassandraPlugin.swift

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ private actor CassandraConnectionActor {
137137
password: String?,
138138
keyspace: String?,
139139
sslMode: SSLMode,
140-
sslCaCertPath: String?
140+
sslCaCertPath: String?,
141+
sslClientCertPath: String?,
142+
sslClientKeyPath: String?,
143+
sslClientKeyPassphrase: String?
141144
) throws {
142145
cluster = cass_cluster_new()
143146
guard let cluster else {
@@ -183,6 +186,21 @@ private actor CassandraConnectionActor {
183186
}
184187
}
185188

189+
let trimmedClientCertPath = sslClientCertPath?.trimmingCharacters(in: .whitespaces) ?? ""
190+
let trimmedClientKeyPath = sslClientKeyPath?.trimmingCharacters(in: .whitespaces) ?? ""
191+
if !trimmedClientCertPath.isEmpty || !trimmedClientKeyPath.isEmpty {
192+
try applyClientCertificate(
193+
to: ssl,
194+
certPath: trimmedClientCertPath,
195+
keyPath: trimmedClientKeyPath,
196+
keyPassphrase: sslClientKeyPassphrase
197+
) {
198+
cass_ssl_free(ssl)
199+
cass_cluster_free(cluster)
200+
self.cluster = nil
201+
}
202+
}
203+
186204
cass_cluster_set_ssl(cluster, ssl)
187205
cass_ssl_free(ssl)
188206
}
@@ -235,6 +253,60 @@ private actor CassandraConnectionActor {
235253
Self.logger.info("Connected to Cassandra at \(host):\(port)")
236254
}
237255

256+
private func applyClientCertificate(
257+
to ssl: OpaquePointer,
258+
certPath: String,
259+
keyPath: String,
260+
keyPassphrase: String?,
261+
cleanup: () -> Void
262+
) throws {
263+
guard !certPath.isEmpty else {
264+
cleanup()
265+
throw SSLHandshakeError.clientCertRequired(serverMessage: "A client certificate is required when a client key is set")
266+
}
267+
guard !keyPath.isEmpty else {
268+
cleanup()
269+
throw SSLHandshakeError.clientCertRequired(serverMessage: "A client key is required when a client certificate is set")
270+
}
271+
272+
guard let certData = FileManager.default.contents(atPath: certPath),
273+
let certString = String(data: certData, encoding: .utf8) else {
274+
cleanup()
275+
throw SSLHandshakeError.clientCertRequired(serverMessage: "Could not read client certificate at \(certPath)")
276+
}
277+
let certResult = cass_ssl_set_cert(ssl, certString)
278+
if certResult != CASS_OK {
279+
cleanup()
280+
throw SSLHandshakeError.clientCertRequired(serverMessage: "Client certificate at \(certPath) is not a valid PEM")
281+
}
282+
283+
guard let keyData = FileManager.default.contents(atPath: keyPath),
284+
let keyString = String(data: keyData, encoding: .utf8) else {
285+
cleanup()
286+
throw SSLHandshakeError.clientKeyInvalid(serverMessage: "Could not read client key at \(keyPath)")
287+
}
288+
let passphrase = keyPassphrase?.isEmpty == false ? keyPassphrase : nil
289+
let keyResult = cass_ssl_set_private_key(ssl, keyString, passphrase)
290+
if keyResult != CASS_OK {
291+
cleanup()
292+
throw Self.privateKeyLoadError(keyPEM: keyString, hasPassphrase: passphrase != nil, keyPath: keyPath)
293+
}
294+
}
295+
296+
static func isEncryptedPrivateKey(_ pem: String) -> Bool {
297+
pem.contains("ENCRYPTED PRIVATE KEY") || (pem.contains("Proc-Type:") && pem.contains("ENCRYPTED"))
298+
}
299+
300+
static func privateKeyLoadError(keyPEM: String, hasPassphrase: Bool, keyPath: String) -> SSLHandshakeError {
301+
guard isEncryptedPrivateKey(keyPEM) else {
302+
return .clientKeyInvalid(serverMessage: "The client key at \(keyPath) is not a valid private key")
303+
}
304+
if hasPassphrase {
305+
return .clientKeyPassphraseIncorrect(serverMessage: "The passphrase for the client key at \(keyPath) is incorrect")
306+
}
307+
return .clientKeyPassphraseRequired(serverMessage: "The client key at \(keyPath) is encrypted. Enter its passphrase.")
308+
}
309+
238310
func close() {
239311
if let session {
240312
let closeFuture = cass_session_close(session)
@@ -921,6 +993,9 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
921993
let keyspace = config.database.isEmpty ? nil : config.database
922994
let legacyCaPath = config.additionalFields["sslCaCertPath"]
923995
let resolvedCaPath = config.ssl.caCertificatePath.isEmpty ? legacyCaPath : config.ssl.caCertificatePath
996+
let clientCertPath = config.ssl.clientCertificatePath.isEmpty ? nil : config.ssl.clientCertificatePath
997+
let clientKeyPath = config.ssl.clientKeyPath.isEmpty ? nil : config.ssl.clientKeyPath
998+
let clientKeyPassphrase = config.additionalFields["sslClientKeyPassphrase"]
924999

9251000
try await connectionActor.connect(
9261001
host: config.host,
@@ -929,7 +1004,10 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
9291004
password: config.password.isEmpty ? nil : config.password,
9301005
keyspace: keyspace,
9311006
sslMode: config.ssl.mode,
932-
sslCaCertPath: resolvedCaPath
1007+
sslCaCertPath: resolvedCaPath,
1008+
sslClientCertPath: clientCertPath,
1009+
sslClientKeyPath: clientKeyPath,
1010+
sslClientKeyPassphrase: clientKeyPassphrase
9331011
)
9341012

9351013
if let keyspace {

Plugins/TableProPluginKit/SSLHandshakeError.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable {
88
case clientCertRequired(serverMessage: String)
99
case cipherMismatch(serverMessage: String)
1010
case unknown(serverMessage: String)
11+
case clientKeyPassphraseRequired(serverMessage: String)
12+
case clientKeyPassphraseIncorrect(serverMessage: String)
13+
case clientKeyInvalid(serverMessage: String)
1114

1215
public var serverMessage: String {
1316
switch self {
@@ -16,6 +19,9 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable {
1619
.untrustedCertificate(let msg),
1720
.hostnameMismatch(let msg),
1821
.clientCertRequired(let msg),
22+
.clientKeyPassphraseRequired(let msg),
23+
.clientKeyPassphraseIncorrect(let msg),
24+
.clientKeyInvalid(let msg),
1925
.cipherMismatch(let msg),
2026
.unknown(let msg):
2127
return msg
@@ -34,6 +40,12 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable {
3440
return String(localized: "The server's TLS certificate does not match the hostname being connected to.")
3541
case .clientCertRequired:
3642
return String(localized: "The server requires a client certificate for TLS mutual authentication.")
43+
case .clientKeyPassphraseRequired:
44+
return String(localized: "The client private key is encrypted and needs a passphrase.")
45+
case .clientKeyPassphraseIncorrect:
46+
return String(localized: "The passphrase for the client private key is incorrect.")
47+
case .clientKeyInvalid:
48+
return String(localized: "The client private key could not be read. It may be malformed or in an unsupported format.")
3749
case .cipherMismatch:
3850
return String(localized: "The server and TablePro could not agree on a TLS cipher or protocol version.")
3951
case .unknown:
@@ -86,6 +98,12 @@ public enum SSLHandshakeError: Error, LocalizedError, Sendable {
8698
return String(localized: "Switch SSL Mode to Verify CA (validates the CA chain but skips hostname check), or update the host field to match the certificate.")
8799
case .clientCertRequired:
88100
return String(localized: "Provide the client certificate and key paths in the SSL tab.")
101+
case .clientKeyPassphraseRequired:
102+
return String(localized: "Open the connection editor, switch to the SSL tab, and enter the Key Passphrase.")
103+
case .clientKeyPassphraseIncorrect:
104+
return String(localized: "Open the connection editor, switch to the SSL tab, and correct the Key Passphrase.")
105+
case .clientKeyInvalid:
106+
return String(localized: "Check that the Client Key path points to a valid PEM private key.")
89107
case .cipherMismatch:
90108
return String(localized: "Update the server's TLS configuration or use a newer database server version that supports modern ciphers.")
91109
case .unknown:

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,10 @@ enum DatabaseDriverFactory {
432432
}
433433
var ssl = connection.sslConfig
434434
var additionalFields = buildAdditionalFields(for: connection, plugin: plugin)
435+
if let sslClientKeyPassphrase = ConnectionStorage.shared.loadSSLClientKeyPassphrase(for: connection.id),
436+
!sslClientKeyPassphrase.isEmpty {
437+
additionalFields["sslClientKeyPassphrase"] = sslClientKeyPassphrase
438+
}
435439
if connection.usesAWSIAM {
436440
if ssl.mode == .disabled || ssl.mode == .preferred {
437441
ssl.mode = .required

TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,8 @@ extension PluginMetadataRegistry {
882882
supportsAddIndex: false,
883883
supportsDropIndex: false,
884884
supportsModifyPrimaryKey: false,
885-
supportsOpportunisticTLS: false
885+
supportsOpportunisticTLS: false,
886+
supportsClientKeyPassphrase: true
886887
),
887888
schema: PluginMetadataSnapshot.SchemaInfo(
888889
defaultSchemaName: "public",
@@ -944,7 +945,8 @@ extension PluginMetadataRegistry {
944945
supportsAddIndex: false,
945946
supportsDropIndex: false,
946947
supportsModifyPrimaryKey: false,
947-
supportsOpportunisticTLS: false
948+
supportsOpportunisticTLS: false,
949+
supportsClientKeyPassphrase: true
948950
),
949951
schema: PluginMetadataSnapshot.SchemaInfo(
950952
defaultSchemaName: "public",

TablePro/Core/Plugins/PluginMetadataRegistry.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ struct PluginMetadataSnapshot: Sendable {
5757
var defaultSSLMode: SSLMode = .disabled
5858
var supportsOpportunisticTLS: Bool = true
5959
var supportsCloudflareTunnel: Bool = true
60+
var supportsClientKeyPassphrase: Bool = false
6061

6162
static let defaults = CapabilityFlags(
6263
supportsSchemaSwitching: false,
@@ -938,7 +939,8 @@ final class PluginMetadataRegistry: @unchecked Sendable {
938939
supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey,
939940
defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled,
940941
supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true,
941-
supportsCloudflareTunnel: driverType.supportsSSH
942+
supportsCloudflareTunnel: driverType.supportsSSH,
943+
supportsClientKeyPassphrase: existingSnapshot?.capabilities.supportsClientKeyPassphrase ?? false
942944
),
943945
schema: PluginMetadataSnapshot.SchemaInfo(
944946
defaultSchemaName: driverType.defaultSchemaName,

TablePro/Core/Services/Export/ConnectionExportService.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ enum ConnectionExportService {
266266
let password = ConnectionStorage.shared.loadPassword(for: connection.id)
267267
let sshPassword = ConnectionStorage.shared.loadSSHPassword(for: connection.id)
268268
let keyPassphrase = ConnectionStorage.shared.loadKeyPassphrase(for: connection.id)
269+
let sslClientKeyPassphrase = ConnectionStorage.shared.loadSSLClientKeyPassphrase(for: connection.id)
269270
let totpSecret = ConnectionStorage.shared.loadTOTPSecret(for: connection.id)
270271

271272
// Collect plugin-specific secure fields
@@ -291,13 +292,15 @@ enum ConnectionExportService {
291292
}
292293

293294
let hasAnyCredential = password != nil || sshPassword != nil
294-
|| keyPassphrase != nil || totpSecret != nil || pluginSecureFields != nil
295+
|| keyPassphrase != nil || sslClientKeyPassphrase != nil
296+
|| totpSecret != nil || pluginSecureFields != nil
295297

296298
if hasAnyCredential {
297299
credentialsMap[String(index)] = ExportableCredentials(
298300
password: password,
299301
sshPassword: sshPassword,
300302
keyPassphrase: keyPassphrase,
303+
sslClientKeyPassphrase: sslClientKeyPassphrase,
301304
totpSecret: totpSecret,
302305
pluginSecureFields: pluginSecureFields
303306
)
@@ -376,6 +379,9 @@ enum ConnectionExportService {
376379
if let keyPassphrase = creds.keyPassphrase {
377380
ConnectionStorage.shared.saveKeyPassphrase(keyPassphrase, for: connectionId)
378381
}
382+
if let sslClientKeyPassphrase = creds.sslClientKeyPassphrase {
383+
ConnectionStorage.shared.saveSSLClientKeyPassphrase(sslClientKeyPassphrase, for: connectionId)
384+
}
379385
if let totpSecret = creds.totpSecret {
380386
ConnectionStorage.shared.saveTOTPSecret(totpSecret, for: connectionId)
381387
}

TablePro/Core/Services/Export/ForeignApp/BeekeeperStudioImporter.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,7 @@ struct BeekeeperStudioImporter: ForeignAppImporter {
247247
password: decrypt(row.password, key: key),
248248
sshPassword: decrypt(row.sshPassword, key: key),
249249
keyPassphrase: decrypt(row.sshKeyfilePassword, key: key),
250+
sslClientKeyPassphrase: nil,
250251
totpSecret: nil,
251252
pluginSecureFields: nil
252253
)

TablePro/Core/Services/Export/ForeignApp/DBeaverImporter.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ struct DBeaverImporter: ForeignAppImporter {
371371
password: password,
372372
sshPassword: sshPassword,
373373
keyPassphrase: nil,
374+
sslClientKeyPassphrase: nil,
374375
totpSecret: nil,
375376
pluginSecureFields: nil
376377
)

TablePro/Core/Services/Export/ForeignApp/DataGripImporter.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ struct DataGripImporter: ForeignAppImporter {
156156
password: password,
157157
sshPassword: sshPassword,
158158
keyPassphrase: keyPassphrase,
159+
sslClientKeyPassphrase: nil,
159160
totpSecret: nil,
160161
pluginSecureFields: nil
161162
),

0 commit comments

Comments
 (0)