From 38be5ba35cbfaee3dee4509e6bf42c08c4846b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BB=A5=E8=B0=B6?= <52121395+fordes123@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:28:58 +0800 Subject: [PATCH] feat(cassandra): add TLS store support --- agents/drivers/cassandra-go/README.md | 15 +- agents/drivers/cassandra-go/config.go | 51 ++++- agents/drivers/cassandra-go/config_file.go | 20 +- .../drivers/cassandra-go/config_file_test.go | 10 + agents/drivers/cassandra-go/go.mod | 6 +- agents/drivers/cassandra-go/go.sum | 10 +- agents/drivers/cassandra-go/main.go | 28 +-- agents/drivers/cassandra-go/tls_stores.go | 181 ++++++++++++++++++ .../drivers/cassandra-go/tls_stores_test.go | 152 +++++++++++++++ .../connection/ConnectionDialog.vue | 94 +++++++++ apps/desktop/src/i18n/locales/az.ts | 12 ++ apps/desktop/src/i18n/locales/en.ts | 12 ++ apps/desktop/src/i18n/locales/es.ts | 12 ++ apps/desktop/src/i18n/locales/it.ts | 12 ++ apps/desktop/src/i18n/locales/ja.ts | 12 ++ apps/desktop/src/i18n/locales/ko.ts | 12 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 12 ++ apps/desktop/src/i18n/locales/tr.ts | 12 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 12 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 12 ++ .../__tests__/cassandraTlsOptions.spec.ts | 61 ++++++ .../src/lib/connection/cassandraTlsOptions.ts | 40 ++++ crates/dbx-core/src/agent_connection.rs | 38 ++++ crates/dbx-core/src/cloud_sync.rs | 101 +++++++++- crates/dbx-core/src/connection_secrets.rs | 147 +++++++++++++- crates/dbx-core/src/storage.rs | 165 +++++++++++++++- 26 files changed, 1203 insertions(+), 36 deletions(-) create mode 100644 agents/drivers/cassandra-go/tls_stores.go create mode 100644 agents/drivers/cassandra-go/tls_stores_test.go create mode 100644 apps/desktop/src/lib/connection/__tests__/cassandraTlsOptions.spec.ts create mode 100644 apps/desktop/src/lib/connection/cassandraTlsOptions.ts diff --git a/agents/drivers/cassandra-go/README.md b/agents/drivers/cassandra-go/README.md index 32cf684988..6e0bec7cc0 100644 --- a/agents/drivers/cassandra-go/README.md +++ b/agents/drivers/cassandra-go/README.md @@ -13,7 +13,8 @@ multi-session JSON-RPC protocol without a JVM. - Astra validation: secure-connect bundle parsing and transport configuration; live Astra credentials were not available - Authentication: username/password and Kerberos/GSSAPI -- TLS: CA verification, optional client certificate/key, hostname verification +- TLS: CA verification, optional client certificate/key, JKS/PKCS#12 stores, + hostname verification - Cloud: DataStax Astra secure connect bundles - Configuration: Java Driver 4 HOCON `configfile` mapping plus native extensions - Metadata: keyspaces, tables, columns, indexes, CQL table DDL, completion search @@ -38,6 +39,8 @@ syntax. | `enablessl` | TLS enablement | | `sslenginefactory` | the standard `DefaultSslEngineFactory` maps to native TLS | | `hostnameverification` | TLS hostname verification; enabled by default | +| `truststorepath`, `truststorepassword` | JKS/PKCS#12 server trust configuration | +| `keystorepath`, `keystorepassword` | JKS/PKCS#12 client certificate configuration | | `user`, `password` | password authentication | | `configfile` | Java Driver 4 HOCON configuration; overrides URL options except contact points and keyspace | | `usekrb5` | Kerberos/GSSAPI authentication using password, keytab, or FILE credential cache | @@ -48,8 +51,8 @@ syntax. The Agent rejects custom Java implementation classes because they cannot be loaded by a native binary. This includes custom authentication, SSL, retry, -reconnection, and load-balancing classes. Java JKS/PKCS12 truststores and -keystores are not read directly; use the native PEM paths described below. +reconnection, and load-balancing classes. The default SSL engine's JKS and +PKCS#12 truststores and keystores are supported directly. ## Java Driver HOCON configuration @@ -66,7 +69,8 @@ Mapped Java Driver paths include: - `advanced.socket.tcp-no-delay` and `keep-alive` - `advanced.protocol.version`, retry policy, and reconnection policy - `advanced.auth-provider` plaintext and Instaclustr Kerberos options -- `advanced.ssl-engine-factory` default TLS and hostname validation +- `advanced.ssl-engine-factory` default TLS, hostname validation, truststore, + and keystore settings Native-only settings can be placed under `dbx.cassandra`: @@ -133,7 +137,8 @@ go test -run TestCassandraIntegration -v Optional variables include `CASSANDRA_TEST_URL_PARAMS`, `CASSANDRA_TEST_SSL`, `CASSANDRA_TEST_CA_CERT_PATH`, `CASSANDRA_TEST_CLIENT_CERT_PATH`, and -`CASSANDRA_TEST_CLIENT_KEY_PATH`. +`CASSANDRA_TEST_CLIENT_KEY_PATH`. Truststore and keystore settings can also be +provided through `CASSANDRA_TEST_URL_PARAMS`. See `bench/README.md` for the archived JDBC comparison workflow and measured Cassandra 4.1.10 results. diff --git a/agents/drivers/cassandra-go/config.go b/agents/drivers/cassandra-go/config.go index 39bd11d2e0..29b3d2d945 100644 --- a/agents/drivers/cassandra-go/config.go +++ b/agents/drivers/cassandra-go/config.go @@ -31,6 +31,10 @@ type cassandraConfig struct { caCertPath string clientCertPath string clientKeyPath string + truststorePath string + truststorePassword string + keystorePath string + keystorePassword string hostVerification bool tcpNoDelay bool keepAlive bool @@ -61,6 +65,10 @@ func parseCassandraConfig(cp connectParams) (cassandraConfig, error) { caCertPath: cp.CACertPath, clientCertPath: cp.ClientCertPath, clientKeyPath: cp.ClientKeyPath, + truststorePath: cp.TruststorePath, + truststorePassword: cp.TruststorePassword, + keystorePath: cp.KeystorePath, + keystorePassword: cp.KeystorePassword, hostVerification: true, tcpNoDelay: true, retryCount: 3, @@ -224,6 +232,14 @@ func applyCassandraURLParams(config *cassandraConfig, params url.Values) error { return fmt.Errorf("invalid host verification option: %w", err) } config.hostVerification = enabled + case "truststorepath": + config.truststorePath = value + case "truststorepassword": + config.truststorePassword = value + case "keystorepath": + config.keystorePath = value + case "keystorepassword": + config.keystorePassword = value case "tcpnodelay": enabled, err := strconv.ParseBool(value) if err != nil { @@ -406,12 +422,20 @@ func (config cassandraConfig) clusterConfig(keyspace string) (*gocql.ClusterConf cluster.Authenticator = gocql.PasswordAuthenticator{Username: config.username, Password: config.password} } if config.secureConnectBundle == "" && config.ssl { - cluster.SslOpts = &gocql.SslOptions{ + sslOptions := &gocql.SslOptions{ CaPath: config.caCertPath, CertPath: config.clientCertPath, KeyPath: config.clientKeyPath, EnableHostVerification: config.hostVerification, } + if config.truststorePath != "" || config.keystorePath != "" { + tlsConfig, err := buildCassandraTLSConfig(config) + if err != nil { + return nil, err + } + sslOptions.Config = tlsConfig + } + cluster.SslOpts = sslOptions } if config.debug { cluster.Logger = gocql.NewLogger(gocql.LogLevelDebug) @@ -435,6 +459,31 @@ func (config *cassandraConfig) finalize() error { if err != nil { return fmt.Errorf("invalid Cassandra secureconnectbundle: %w", err) } + if config.secureConnectBundle == "" { + config.truststorePath, err = normalizeLocalFilePath(config.truststorePath) + if err != nil { + return fmt.Errorf("invalid Cassandra truststore path: %w", err) + } + config.keystorePath, err = normalizeLocalFilePath(config.keystorePath) + if err != nil { + return fmt.Errorf("invalid Cassandra keystore path: %w", err) + } + if config.truststorePassword != "" && config.truststorePath == "" { + return fmt.Errorf("Cassandra truststore password requires a truststore path") + } + if config.keystorePassword != "" && config.keystorePath == "" { + return fmt.Errorf("Cassandra keystore password requires a keystore path") + } + if config.truststorePath != "" && strings.TrimSpace(config.caCertPath) != "" { + return fmt.Errorf("Cassandra truststore cannot be combined with a PEM CA certificate") + } + if config.keystorePath != "" && (strings.TrimSpace(config.clientCertPath) != "" || strings.TrimSpace(config.clientKeyPath) != "") { + return fmt.Errorf("Cassandra keystore cannot be combined with PEM client certificate settings") + } + if config.truststorePath != "" || config.keystorePath != "" { + config.ssl = true + } + } if config.secureConnectBundle != "" && config.kerberos.enabled { return fmt.Errorf("Cassandra secure connect bundles cannot be combined with Kerberos authentication") } diff --git a/agents/drivers/cassandra-go/config_file.go b/agents/drivers/cassandra-go/config_file.go index d20b0d7a69..45686f66f3 100644 --- a/agents/drivers/cassandra-go/config_file.go +++ b/agents/drivers/cassandra-go/config_file.go @@ -263,13 +263,25 @@ func applyHOCONSSL(config *cassandraConfig, parsed *hocon.Config) error { config.hostVerification = value config.ssl = true } - for _, path := range []string{prefix + "truststore-path", prefix + "keystore-path"} { - if value, ok, err := hoconString(parsed, path); err != nil { + stringMappings := []struct { + path string + target *string + }{ + {prefix + "truststore-path", &config.truststorePath}, + {prefix + "truststore-password", &config.truststorePassword}, + {prefix + "keystore-path", &config.keystorePath}, + {prefix + "keystore-password", &config.keystorePassword}, + } + for _, mapping := range stringMappings { + if value, ok, err := hoconString(parsed, mapping.path); err != nil { return err - } else if ok && value != "" { - return fmt.Errorf("Java truststore and keystore files are not supported; use dbx.cassandra.tls PEM paths") + } else if ok { + *mapping.target = value } } + if config.truststorePath != "" || config.keystorePath != "" { + config.ssl = true + } return nil } diff --git a/agents/drivers/cassandra-go/config_file_test.go b/agents/drivers/cassandra-go/config_file_test.go index ee9aec2351..c56d7dbb03 100644 --- a/agents/drivers/cassandra-go/config_file_test.go +++ b/agents/drivers/cassandra-go/config_file_test.go @@ -52,6 +52,10 @@ datastax-java-driver { ssl-engine-factory { class = DefaultSslEngineFactory hostname-validation = false + truststore-path = /certs/client.truststore + truststore-password = trust-secret + keystore-path = /certs/client.keystore + keystore-password = key-secret } } } @@ -110,6 +114,12 @@ datastax-java-driver { if !config.ssl || config.hostVerification { t.Fatalf("unexpected TLS config: %#v", config) } + if config.truststorePath != filepath.Clean("/certs/client.truststore") || config.truststorePassword != "trust-secret" { + t.Fatalf("unexpected truststore config: %#v", config) + } + if config.keystorePath != filepath.Clean("/certs/client.keystore") || config.keystorePassword != "key-secret" { + t.Fatalf("unexpected keystore config: %#v", config) + } } func TestCassandraConfigFileSupportsKerberosLoginOptionCasing(t *testing.T) { diff --git a/agents/drivers/cassandra-go/go.mod b/agents/drivers/cassandra-go/go.mod index f2929f5d59..0d5a8a130d 100644 --- a/agents/drivers/cassandra-go/go.mod +++ b/agents/drivers/cassandra-go/go.mod @@ -8,6 +8,8 @@ require ( github.com/gurkankaymak/hocon v1.3.0 github.com/jcmturner/gofork v1.7.6 github.com/jcmturner/gokrb5/v8 v8.4.4 + github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 + software.sslmate.com/src/go-pkcs12 v0.7.3 ) require ( @@ -23,7 +25,7 @@ require ( github.com/jcmturner/rpc/v2 v2.0.3 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.6.0 // indirect - golang.org/x/net v0.7.0 // indirect + golang.org/x/crypto v0.11.0 // indirect + golang.org/x/net v0.10.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect ) diff --git a/agents/drivers/cassandra-go/go.sum b/agents/drivers/cassandra-go/go.sum index 511d4254cb..4716ad695e 100644 --- a/agents/drivers/cassandra-go/go.sum +++ b/agents/drivers/cassandra-go/go.sum @@ -45,6 +45,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 h1:2nosf3P75OZv2/ZO/9Px5ZgZ5gbKrzA3joN1QMfOGMQ= +github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pierrec/lz4/v4 v4.1.8 h1:ieHkV+i2BRzngO4Wd/3HGowuZStgq6QkPsD1eolNAO4= github.com/pierrec/lz4/v4 v4.1.8/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -74,16 +76,18 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -112,3 +116,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +software.sslmate.com/src/go-pkcs12 v0.7.3 h1:JBQD3FDqYjTeyDAeZQklj2ar88ykBLtALloPJHyAauU= +software.sslmate.com/src/go-pkcs12 v0.7.3/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/agents/drivers/cassandra-go/main.go b/agents/drivers/cassandra-go/main.go index b12fc1a4d3..fc264cb690 100644 --- a/agents/drivers/cassandra-go/main.go +++ b/agents/drivers/cassandra-go/main.go @@ -35,18 +35,22 @@ type response struct { } type connectParams struct { - Host string `json:"host"` - Port int `json:"port"` - Database string `json:"database"` - Username string `json:"username"` - Password string `json:"password"` - URLParams string `json:"url_params"` - ConnectionString string `json:"connection_string"` - SSL bool `json:"ssl"` - CACertPath string `json:"ca_cert_path"` - ClientCertPath string `json:"client_cert_path"` - ClientKeyPath string `json:"client_key_path"` - SessionRole string `json:"sessionRole"` + Host string `json:"host"` + Port int `json:"port"` + Database string `json:"database"` + Username string `json:"username"` + Password string `json:"password"` + URLParams string `json:"url_params"` + ConnectionString string `json:"connection_string"` + SSL bool `json:"ssl"` + CACertPath string `json:"ca_cert_path"` + ClientCertPath string `json:"client_cert_path"` + ClientKeyPath string `json:"client_key_path"` + TruststorePath string `json:"truststore_path"` + TruststorePassword string `json:"truststore_password"` + KeystorePath string `json:"keystore_path"` + KeystorePassword string `json:"keystore_password"` + SessionRole string `json:"sessionRole"` } type queryOptions struct { diff --git a/agents/drivers/cassandra-go/tls_stores.go b/agents/drivers/cassandra-go/tls_stores.go new file mode 100644 index 0000000000..b8ab90feee --- /dev/null +++ b/agents/drivers/cassandra-go/tls_stores.go @@ -0,0 +1,181 @@ +package main + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "os" + + keystore "github.com/pavlo-v-chernykh/keystore-go/v4" + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +const jksMagic = 0xfeedfeed + +func buildCassandraTLSConfig(config cassandraConfig) (*tls.Config, error) { + tlsConfig := &tls.Config{InsecureSkipVerify: !config.hostVerification} + if config.truststorePath != "" { + certificates, err := loadTruststore(config.truststorePath, config.truststorePassword) + if err != nil { + return nil, fmt.Errorf("load Cassandra truststore %q: %w", config.truststorePath, err) + } + roots := x509.NewCertPool() + for _, certificate := range certificates { + roots.AddCert(certificate) + } + tlsConfig.RootCAs = roots + } + if config.keystorePath != "" { + certificate, err := loadKeystore(config.keystorePath, config.keystorePassword) + if err != nil { + return nil, fmt.Errorf("load Cassandra keystore %q: %w", config.keystorePath, err) + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + return tlsConfig, nil +} + +func loadTruststore(path string, password string) ([]*x509.Certificate, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if isJKS(data) { + return loadJKSTruststore(data, password) + } + certificates, err := pkcs12.DecodeTrustStore(data, password) + if err == nil && len(certificates) > 0 { + return certificates, nil + } + _, certificate, chain, chainErr := pkcs12.DecodeChain(data, password) + if chainErr != nil { + if err != nil { + return nil, fmt.Errorf("decode PKCS#12 truststore: %w", err) + } + return nil, fmt.Errorf("PKCS#12 truststore contains no trusted certificates") + } + return append([]*x509.Certificate{certificate}, chain...), nil +} + +func loadKeystore(path string, password string) (tls.Certificate, error) { + data, err := os.ReadFile(path) + if err != nil { + return tls.Certificate{}, err + } + if isJKS(data) { + return loadJKSKeystore(data, password) + } + privateKey, certificate, caCertificates, err := pkcs12.DecodeChain(data, password) + if err != nil { + return tls.Certificate{}, fmt.Errorf("decode PKCS#12 keystore: %w", err) + } + chain := make([][]byte, 0, 1+len(caCertificates)) + chain = append(chain, certificate.Raw) + for _, caCertificate := range caCertificates { + chain = append(chain, caCertificate.Raw) + } + return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: certificate}, nil +} + +func isJKS(data []byte) bool { + return len(data) >= 4 && binary.BigEndian.Uint32(data[:4]) == jksMagic +} + +func loadJKS(data []byte, password string) (keystore.KeyStore, error) { + store := keystore.New() + passwordBytes := []byte(password) + defer clear(passwordBytes) + if err := store.Load(bytes.NewReader(data), passwordBytes); err != nil { + return keystore.KeyStore{}, fmt.Errorf("decode JKS: %w", err) + } + return store, nil +} + +func loadJKSTruststore(data []byte, password string) ([]*x509.Certificate, error) { + store, err := loadJKS(data, password) + if err != nil { + return nil, err + } + certificates := make([]*x509.Certificate, 0) + for _, alias := range store.Aliases() { + switch { + case store.IsTrustedCertificateEntry(alias): + entry, err := store.GetTrustedCertificateEntry(alias) + if err != nil { + return nil, fmt.Errorf("read trusted certificate %q: %w", alias, err) + } + certificate, err := x509.ParseCertificate(entry.Certificate.Content) + if err != nil { + return nil, fmt.Errorf("parse trusted certificate %q: %w", alias, err) + } + certificates = append(certificates, certificate) + case store.IsPrivateKeyEntry(alias): + chain, err := store.GetPrivateKeyEntryCertificateChain(alias) + if err != nil { + return nil, fmt.Errorf("read certificate chain %q: %w", alias, err) + } + for _, entry := range chain { + certificate, err := x509.ParseCertificate(entry.Content) + if err != nil { + return nil, fmt.Errorf("parse certificate chain %q: %w", alias, err) + } + certificates = append(certificates, certificate) + } + } + } + if len(certificates) == 0 { + return nil, fmt.Errorf("JKS truststore contains no trusted certificates") + } + return certificates, nil +} + +func loadJKSKeystore(data []byte, password string) (tls.Certificate, error) { + store, err := loadJKS(data, password) + if err != nil { + return tls.Certificate{}, err + } + passwordBytes := []byte(password) + defer clear(passwordBytes) + for _, alias := range store.Aliases() { + if !store.IsPrivateKeyEntry(alias) { + continue + } + entry, err := store.GetPrivateKeyEntry(alias, passwordBytes) + if err != nil { + return tls.Certificate{}, fmt.Errorf("read private key %q: %w", alias, err) + } + privateKey, err := parsePrivateKey(entry.PrivateKey) + if err != nil { + return tls.Certificate{}, fmt.Errorf("parse private key %q: %w", alias, err) + } + chain := make([][]byte, 0, len(entry.CertificateChain)) + for _, certificate := range entry.CertificateChain { + chain = append(chain, certificate.Content) + } + if len(chain) == 0 { + return tls.Certificate{}, fmt.Errorf("private key %q has no certificate chain", alias) + } + leaf, err := x509.ParseCertificate(chain[0]) + if err != nil { + return tls.Certificate{}, fmt.Errorf("parse client certificate %q: %w", alias, err) + } + return tls.Certificate{Certificate: chain, PrivateKey: privateKey, Leaf: leaf}, nil + } + return tls.Certificate{}, fmt.Errorf("JKS keystore contains no private key entries") +} + +func parsePrivateKey(data []byte) (any, error) { + if value, err := x509.ParsePKCS8PrivateKey(data); err == nil { + return value, nil + } + if value, err := x509.ParsePKCS1PrivateKey(data); err == nil { + return value, nil + } + if value, err := x509.ParseECPrivateKey(data); err == nil { + return value, nil + } + return nil, errors.New("unsupported private key encoding") +} diff --git a/agents/drivers/cassandra-go/tls_stores_test.go b/agents/drivers/cassandra-go/tls_stores_test.go new file mode 100644 index 0000000000..1968177bf0 --- /dev/null +++ b/agents/drivers/cassandra-go/tls_stores_test.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + keystore "github.com/pavlo-v-chernykh/keystore-go/v4" + pkcs12 "software.sslmate.com/src/go-pkcs12" +) + +const tlsStoreTestPassword = "changeit" + +func TestLoadJKSStores(t *testing.T) { + _, certificate, privateKeyDER := testTLSIdentity(t) + store := keystore.New() + if err := store.SetTrustedCertificateEntry("ca", keystore.TrustedCertificateEntry{ + CreationTime: time.Now(), + Certificate: keystore.Certificate{Type: "X509", Content: certificate.Raw}, + }); err != nil { + t.Fatal(err) + } + if err := store.SetPrivateKeyEntry("client", keystore.PrivateKeyEntry{ + CreationTime: time.Now(), + PrivateKey: privateKeyDER, + CertificateChain: []keystore.Certificate{ + {Type: "X509", Content: certificate.Raw}, + }, + }, []byte(tlsStoreTestPassword)); err != nil { + t.Fatal(err) + } + var encoded bytes.Buffer + if err := store.Store(&encoded, []byte(tlsStoreTestPassword)); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "client.jks") + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + trusted, err := loadTruststore(path, tlsStoreTestPassword) + if err != nil { + t.Fatal(err) + } + identity, err := loadKeystore(path, tlsStoreTestPassword) + if err != nil { + t.Fatal(err) + } + if len(trusted) != 2 || identity.Leaf == nil { + t.Fatalf("unexpected JKS contents: trusted=%d identity=%#v", len(trusted), identity) + } +} + +func TestLoadPKCS12Stores(t *testing.T) { + privateKey, certificate, _ := testTLSIdentity(t) + truststore, err := pkcs12.Modern.EncodeTrustStore([]*x509.Certificate{certificate}, tlsStoreTestPassword) + if err != nil { + t.Fatal(err) + } + keystoreData, err := pkcs12.Modern.Encode(privateKey, certificate, nil, tlsStoreTestPassword) + if err != nil { + t.Fatal(err) + } + directory := t.TempDir() + truststorePath := filepath.Join(directory, "client-trust.p12") + keystorePath := filepath.Join(directory, "client-key.p12") + if err := os.WriteFile(truststorePath, truststore, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keystorePath, keystoreData, 0o600); err != nil { + t.Fatal(err) + } + + tlsConfig, err := buildCassandraTLSConfig(cassandraConfig{ + truststorePath: truststorePath, + truststorePassword: tlsStoreTestPassword, + keystorePath: keystorePath, + keystorePassword: tlsStoreTestPassword, + hostVerification: true, + }) + if err != nil { + t.Fatal(err) + } + if tlsConfig.RootCAs == nil || len(tlsConfig.Certificates) != 1 || tlsConfig.InsecureSkipVerify { + t.Fatalf("unexpected PKCS#12 TLS config: %#v", tlsConfig) + } +} + +func TestParseCassandraConfigRejectsStorePasswordWithoutPath(t *testing.T) { + _, err := parseCassandraConfig(connectParams{Host: "localhost", TruststorePassword: "secret"}) + if err == nil { + t.Fatal("expected truststore path validation error") + } +} + +func TestSecureConnectBundleIgnoresManualTLSStoreSettings(t *testing.T) { + config, err := parseCassandraConfig(connectParams{ + Username: "token", + Password: "secret", + CACertPath: "ca.pem", + URLParams: url.Values{ + "secureconnectbundle": []string{"bundle.zip"}, + "truststorepath": []string{"https://ignored.example/truststore"}, + "truststorepassword": []string{"ignored"}, + }.Encode(), + }) + if err != nil { + t.Fatal(err) + } + if config.secureConnectBundle != "bundle.zip" { + t.Fatalf("unexpected secure connect bundle: %q", config.secureConnectBundle) + } +} + +func testTLSIdentity(t *testing.T) (*ecdsa.PrivateKey, *x509.Certificate, []byte) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "cassandra-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + IsCA: true, + } + certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + certificate, err := x509.ParseCertificate(certificateDER) + if err != nil { + t.Fatal(err) + } + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + return privateKey, certificate, privateKeyDER +} diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index dd7fc9e95c..e9a20f7525 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -62,6 +62,7 @@ import { SQLITE_DATABASE_FILE_EXTENSIONS } from "@/lib/database/databaseFileDete import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout"; import { consulAgentAddressesMatch } from "@/lib/consul/agentTarget"; import { appendConnectionErrorHints, isJdbcMissingRuntimeDependencyError } from "@/lib/connection/connectionErrorHints"; +import { buildCassandraExternalConfig, cassandraTlsConfigFromExternalConfig, type CassandraTlsConfig } from "@/lib/connection/cassandraTlsOptions"; import { preventDialogDocumentSelectAll } from "@/lib/connection/dialogTextSelection"; import { postgresLegacyTlsEnabled, postgresTlsModeForForm, setPostgresLegacyTlsEnabled } from "@/lib/connection/postgresTlsMode"; import { buildMqKafkaConnectionExtra, mqKafkaConnectionTarget, resolveMqKafkaConnectionSource, type MqKafkaConnectionSource } from "@/lib/connection/mqKafkaConnection"; @@ -396,6 +397,12 @@ const defaultForm = (): ConnectionForm => ({ save_password: true, }); +const cassandraTls = reactive(cassandraTlsConfigFromExternalConfig(undefined)); + +function resetCassandraTlsFields(externalConfig: unknown) { + Object.assign(cassandraTls, cassandraTlsConfigFromExternalConfig(externalConfig)); +} + const elasticsearchConnectionMode = ref("direct"); const elasticsearchKibanaBasePath = ref(""); const elasticsearchConnectivityCheckPath = ref(""); @@ -2333,6 +2340,9 @@ function applyProfile(val: string, preserveConnectionFields = false) { if (profile.type !== "elasticsearch" || previousDatabaseType !== "elasticsearch") { resetElasticsearchProxyFields(); } + if (profile.type !== "cassandra" || previousDatabaseType !== "cassandra") { + resetCassandraTlsFields(undefined); + } if (!preserveConnectionFields) { oracleTnsAdminPath.value = ""; form.value.port = profile.port; @@ -2572,6 +2582,7 @@ watch( } else { resetMqFields(); } + resetCassandraTlsFields(config.db_type === "cassandra" ? config.external_config : undefined); if (config.db_type === "nacos") { hydrateNacosFields(config.external_config); } else { @@ -2643,6 +2654,7 @@ watch( selectedType.value = "mysql"; customDriverName.value = ""; resetMqFields(); + resetCassandraTlsFields(undefined); resetNacosFields(); resetInfluxDbFields(); resetElasticsearchProxyFields(); @@ -3012,6 +3024,7 @@ const tlsCapableDatabaseTypes = new Set([ "chromadb", "influxdb", "victoriametrics", + "cassandra", ]); const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type)); const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics"); @@ -3922,6 +3935,20 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo config.database = undefined; config.connection_string = undefined; config.url_params = ""; + } else if (config.db_type === "cassandra") { + // Go 侧在配置了 truststore/keystore 时会自动启用 TLS,前端保持一致:有 store 配置就不丢弃 external_config。 + const cassandraHasTlsStore = Boolean(cassandraTls.truststore_path.trim() || cassandraTls.truststore_password) || Boolean(cassandraTls.keystore_path.trim() || cassandraTls.keystore_password); + if (!config.ssl && !cassandraHasTlsStore) { + config.external_config = undefined; + } else { + if (cassandraTls.truststore_password && !cassandraTls.truststore_path.trim()) { + throw new Error(t("connection.cassandraTruststorePasswordRequiresPath")); + } + if (cassandraTls.keystore_password && !cassandraTls.keystore_path.trim()) { + throw new Error(t("connection.cassandraKeystorePasswordRequiresPath")); + } + config.external_config = buildCassandraExternalConfig(cassandraTls); + } } else if (config.db_type === "nacos") { const nacosConfig = buildNacosAdminConfig(); config.external_config = nacosConfig; @@ -5476,6 +5503,23 @@ async function browseCaCertPath() { } } +async function browseCassandraStore(target: "truststore" | "keystore") { + if (!isTauriRuntime()) return; + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + title: target === "truststore" ? t("connection.cassandraTruststoreBrowse") : t("connection.cassandraKeystoreBrowse"), + multiple: false, + filters: [ + { name: "Java KeyStore / PKCS#12", extensions: ["jks", "p12", "pfx", "truststore", "keystore"] }, + { name: "All Files", extensions: ["*"] }, + ], + }); + if (typeof selected === "string") { + if (target === "truststore") cassandraTls.truststore_path = selected; + else cassandraTls.keystore_path = selected; + } +} + async function browseDamengSslFilesPath() { if (isTauriRuntime()) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -7913,6 +7957,56 @@ function openExternalUrl(url: string) { + +