diff --git a/docs/README_zh.md b/docs/README_zh.md index b595ad9..df6da6b 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -22,7 +22,7 @@ dmPython 是达梦数据库(DM8)的原生 Python 驱动程序,遵循 [Pyth - **构建支持范围**:macOS 14+ ARM64、CPython 3.9–3.13。数据库行为仅以已有集成测试证据为准。 - **Best-effort(尽力支持)**:尚未纳入 CI 覆盖的扩展使用场景。 - **Not guaranteed(不保证)**:生产 SLA 承诺、厂商认证兼容性与闭源组件支持协议。 -- **连接安全**:`ssl_path` 支持使用客户端证书与私钥连接启用加密的 DM8。目录需包含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem`;服务端证书没有 SAN 的旧版本还需提供与服务端完全一致的 `server-cert.pem`。指定 `ssl_path` 时,未协商加密的连接会报错。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 仍不支持。 +- **连接安全**:`ssl_path` 支持使用客户端证书与私钥连接启用加密的 DM8。目录需包含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem`;服务端证书没有 SAN 的旧版本还需提供与服务端完全一致的 `server-cert.pem`。指定 `ssl_path` 时,未协商加密的连接会报错。`ssl_pwd` 支持传统 PEM 加密私钥;加密 PKCS#8 私钥和 UKey 仍不支持。 - **主备与 MPP**:读写分离模式 1 和 4 已在本机 DM8 主备环境、自动提交模式下验证;通过双端点服务名建立的新连接也通过了自动接管后的回归。MPP 全局和本地登录已在本机两节点集群验证分布式读写。 ## 路线图与状态 diff --git a/docs/api-reference.md b/docs/api-reference.md index 963eba3..0adc712 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -62,7 +62,7 @@ dmPython.connect( - IPv6 地址使用方括号,例如 `server="[::1]"`;`dsn` 可写为 `"[::1]:5236"`。 - `dmsvc_path` 指向包含 `dm_svc.conf` 的目录;连接时可把 `server` 设为配置文件中的服务名。双端点服务名配置 `LOGIN_MODE=1` 后,已在本机 DM8 主备环境验证故障自动接管后的**新连接**会选择晋升的新主库;已有连接的自动恢复尚未验证。 - `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动。读写分离模式 1 和 4 已在本机 DM8 主备环境中验证自动提交模式下的查询路由;MPP 全局和本地登录已在本机两节点集群验证分布式读写。 -- `ssl_path` 指向含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem` 的目录。服务端证书没有 SAN 时还需提供准确的 `server-cert.pem`,用于证书固定校验;有 SAN 的证书按 CA 链和主机名校验。设置后若服务端未协商加密,连接失败。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 暂不支持。 +- `ssl_path` 指向含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem` 的目录。服务端证书没有 SAN 时还需提供准确的 `server-cert.pem`,用于证书固定校验;有 SAN 的证书按 CA 链和主机名校验。设置后若服务端未协商加密,连接失败。`ssl_pwd` 可解密传统 PEM 格式的加密客户端私钥,必须与 `ssl_path` 一起使用;加密 PKCS#8 私钥尚不支持。非空的 `ukey_name`、`ukey_pin` 暂不支持。 - `user` 支持 `user/password@server:port[/schema][?catalog=...]` 形式。 - `login_timeout` 以毫秒为单位,默认 5000,限制首次建连握手;设为 0 表示不限制。`connection_timeout` 以秒为单位,默认 0 不限制,限制 SQL 执行时间。 - 常量参数建议使用模块常量(如 `DSQL_AUTOCOMMIT_ON`、`ISO_LEVEL_READ_COMMITTED`)。 diff --git a/docs/test-results/2026-09-27-ssl-connection.md b/docs/test-results/2026-09-27-ssl-connection.md index 0a2ddb4..716def0 100644 --- a/docs/test-results/2026-09-27-ssl-connection.md +++ b/docs/test-results/2026-09-27-ssl-connection.md @@ -19,4 +19,11 @@ The local test uses DM8's bundled legacy certificate without SAN, so it exercises exact certificate pinning. CA and hostname verification for modern SAN certificates is implemented but needs a server with a modern certificate -for an end-to-end regression. `ssl_pwd` and UKey login remain unsupported. +for an end-to-end regression. + +The ARM macOS Python 3.10 extension also connected to the SSL-enabled DM8 +instance with the bundled RSA client key re-encrypted in traditional PEM +format. `ssl_pwd="test+ssl&pwd 123"` succeeded and executed a query; a missing +or wrong password failed. All five SSL tests passed locally. The password +without `ssl_path` was rejected on the ordinary DM8 instance. Encrypted +PKCS#8 keys and UKey login remain unsupported. diff --git a/dpi_bridge/dpi_conn.go b/dpi_bridge/dpi_conn.go index 530cddf..1eb53b6 100644 --- a/dpi_bridge/dpi_conn.go +++ b/dpi_bridge/dpi_conn.go @@ -62,6 +62,7 @@ type connHandle struct { appName string compressMsg int sslPath string + sslPassword string svcPath string mppLogin int rwSeparate int @@ -257,10 +258,21 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd } else { conn.sslPath = C.GoString((*C.char)(val)) } - case DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN: + case DSQL_ATTR_SSL_PWD: + if conn.conn != nil { + conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_pwd can only be set before login"} + return DSQL_ERROR + } + if val == nil { + conn.sslPassword = "" + } else if valLen > 0 { + conn.sslPassword = C.GoStringN((*C.char)(val), C.int(valLen)) + } else { + conn.sslPassword = C.GoString((*C.char)(val)) + } + case DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN: if val != nil && C.GoString((*C.char)(val)) != "" { name := map[int32]string{ - DSQL_ATTR_SSL_PWD: "ssl_pwd", DSQL_ATTR_UKEY_NAME: "ukey_name", DSQL_ATTR_UKEY_PIN: "ukey_pin", }[attr] conn.lastErr = &diagInfo{errorCode: -1, message: name + " is not supported by this bridge"} @@ -502,6 +514,13 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI if conn.sslPath != "" { params = append(params, "sslFilesPath="+url.QueryEscape(conn.sslPath)) } + if conn.sslPassword != "" { + if conn.sslPath == "" { + conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_pwd requires ssl_path"} + return DSQL_ERROR + } + params = append(params, "sslKeyPassword="+url.QueryEscape(conn.sslPassword)) + } if conn.svcPath != "" { params = append(params, "svcConfPath="+url.QueryEscape(filepath.Join(conn.svcPath, "dm_svc.conf"))) } diff --git a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md index 422bd51..64c6bf2 100644 --- a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md +++ b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md @@ -64,6 +64,16 @@ - Regression: `tests/ssl/test_ssl_connection.py` uses a real SSL-enabled DM8 instance, including wrong and missing pins. +## Patch: encrypted traditional PEM client keys + +- Files: `a.go`, `n.go`, `security/zzi.go` +- Pass `ssl_pwd` through the DPI bridge and connector, preserving special + characters in the password. Decrypt traditional encrypted PEM private keys + before the TLS handshake; reject a missing or wrong password and identify + encrypted PKCS#8 keys as unsupported. +- Regression: `security/zzi_test.go` checks local TLS handshakes; the real DM8 + `tests/ssl/test_ssl_connection.py` case uses an encrypted RSA client key. + ## Patch: initial connection timeout through endpoint groups - Files: `a.go`, `n.go`, `x.go`, `y.go`, `m.go` diff --git a/dpi_bridge/third_party/chunanyong_dm/a.go b/dpi_bridge/third_party/chunanyong_dm/a.go index d0f55ac..492c99e 100644 --- a/dpi_bridge/third_party/chunanyong_dm/a.go +++ b/dpi_bridge/third_party/chunanyong_dm/a.go @@ -890,7 +890,7 @@ func (dm_build_680 *dm_build_414) dm_build_679(dm_build_681 int, dm_build_682 [] } func (dm_build_687 *dm_build_414) dm_build_686(dm_build_688 bool) (dm_build_689 error) { - if dm_build_687.dm_build_416, dm_build_689 = security.NewTLSFromTCP(dm_build_687.dm_build_415, dm_build_687.dm_build_418.dmConnector.sslCertPath, dm_build_687.dm_build_418.dmConnector.sslKeyPath, dm_build_687.dm_build_418.dmConnector.sslFilesPath, dm_build_687.dm_build_418.dmConnector.host); dm_build_689 != nil { + if dm_build_687.dm_build_416, dm_build_689 = security.NewTLSFromTCP(dm_build_687.dm_build_415, dm_build_687.dm_build_418.dmConnector.sslCertPath, dm_build_687.dm_build_418.dmConnector.sslKeyPath, dm_build_687.dm_build_418.dmConnector.sslFilesPath, dm_build_687.dm_build_418.dmConnector.host, dm_build_687.dm_build_418.dmConnector.sslKeyPassword); dm_build_689 != nil { return } if !dm_build_688 { diff --git a/dpi_bridge/third_party/chunanyong_dm/n.go b/dpi_bridge/third_party/chunanyong_dm/n.go index 0c818de..535d99f 100644 --- a/dpi_bridge/third_party/chunanyong_dm/n.go +++ b/dpi_bridge/third_party/chunanyong_dm/n.go @@ -104,6 +104,7 @@ const ( SslCertPathKey = "sslCertPath" SslKeyPathKey = "sslKeyPath" SslFilesPathKey = "sslFilesPath" + SslKeyPasswordKey = "sslKeyPassword" KerberosLoginConfPathKey = "kerberosLoginConfPath" UKeyNameKey = "uKeyName" UKeyPinKey = "uKeyPin" @@ -390,7 +391,8 @@ type DmConnector struct { sslKeyPath string - sslFilesPath string + sslFilesPath string + sslKeyPassword string kerberosLoginConfPath string @@ -596,6 +598,7 @@ func (c *DmConnector) setAttributes(props *Properties) error { c.batchNotOnCall = props.GetBool(BatchNotOnCallKey, c.batchNotOnCall) c.isBdtaRS = props.GetBool(IsBdtaRSKey, c.isBdtaRS) c.sslFilesPath = props.GetTrimString(SslFilesPathKey, c.sslFilesPath) + c.sslKeyPassword = props.GetString(SslKeyPasswordKey, c.sslKeyPassword) c.sslCertPath = props.GetTrimString(SslCertPathKey, c.sslCertPath) if c.sslCertPath == "" && c.sslFilesPath != "" { c.sslCertPath = filepath.Join(c.sslFilesPath, "client-cert.pem") @@ -761,7 +764,8 @@ func (c *DmConnector) parseDSN(dsn string) (*Properties, string, string, error) if kv != nil && len(kv) > 1 { value := kv[1] if kv[0] == AppNameKey || kv[0] == "svcConfPath" || - kv[0] == SslFilesPathKey || kv[0] == SslCertPathKey || kv[0] == SslKeyPathKey { + kv[0] == SslFilesPathKey || kv[0] == SslCertPathKey || + kv[0] == SslKeyPathKey || kv[0] == SslKeyPasswordKey { decoded, err := url.QueryUnescape(value) if err != nil { return nil, "", "", err diff --git a/dpi_bridge/third_party/chunanyong_dm/security/zzi.go b/dpi_bridge/third_party/chunanyong_dm/security/zzi.go index 045d168..f22019a 100644 --- a/dpi_bridge/third_party/chunanyong_dm/security/zzi.go +++ b/dpi_bridge/third_party/chunanyong_dm/security/zzi.go @@ -22,11 +22,11 @@ import ( // var dmHome = flag.String("DM_HOME", "", "Where DMDB installed") var flagLock = sync.Mutex{} -func NewTLSFromTCP(conn net.Conn, sslCertPath, sslKeyPath, sslFilesPath, serverName string) (*tls.Conn, error) { +func NewTLSFromTCP(conn net.Conn, sslCertPath, sslKeyPath, sslFilesPath, serverName, sslKeyPassword string) (*tls.Conn, error) { if sslCertPath == "" || sslKeyPath == "" || sslFilesPath == "" { return nil, errors.New("SSL certificate, key, and CA directory are required") } - cert, err := tls.LoadX509KeyPair(sslCertPath, sslKeyPath) + cert, err := loadClientKeyPair(sslCertPath, sslKeyPath, sslKeyPassword) if err != nil { return nil, err } @@ -87,3 +87,37 @@ func NewTLSFromTCP(conn net.Conn, sslCertPath, sslKeyPath, sslFilesPath, serverN } return tlsConn, nil } + +func loadClientKeyPair(certPath, keyPath, password string) (tls.Certificate, error) { + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return tls.Certificate{}, err + } + block, _ := pem.Decode(keyPEM) + if block == nil { + return tls.Certificate{}, errors.New("SSL private key is not PEM encoded") + } + if block.Type == "ENCRYPTED PRIVATE KEY" { + return tls.Certificate{}, errors.New("encrypted PKCS#8 SSL private keys are not supported") + } + if !x509.IsEncryptedPEMBlock(block) { + return tls.LoadX509KeyPair(certPath, keyPath) + } + if password == "" { + return tls.Certificate{}, errors.New("encrypted SSL private key requires ssl_pwd") + } + der, err := x509.DecryptPEMBlock(block, []byte(password)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to decrypt SSL private key: %w", err) + } + defer func() { + for i := range der { + der[i] = 0 + } + }() + certPEM, err := os.ReadFile(certPath) + if err != nil { + return tls.Certificate{}, err + } + return tls.X509KeyPair(certPEM, pem.EncodeToMemory(&pem.Block{Type: block.Type, Bytes: der})) +} diff --git a/dpi_bridge/third_party/chunanyong_dm/security/zzi_test.go b/dpi_bridge/third_party/chunanyong_dm/security/zzi_test.go index 43b6c09..a744244 100644 --- a/dpi_bridge/third_party/chunanyong_dm/security/zzi_test.go +++ b/dpi_bridge/third_party/chunanyong_dm/security/zzi_test.go @@ -86,7 +86,7 @@ func testTLSFiles(t *testing.T) (string, tls.Certificate) { return dir, serverCert } -func testTLSHandshake(t *testing.T, dir string, serverCert tls.Certificate, serverName string) error { +func testTLSHandshake(t *testing.T, dir string, serverCert tls.Certificate, serverName, password string) error { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -108,7 +108,7 @@ func testTLSHandshake(t *testing.T, dir string, serverCert tls.Certificate, serv if err != nil { t.Fatal(err) } - client, err := NewTLSFromTCP(clientSocket, filepath.Join(dir, "client-cert.pem"), filepath.Join(dir, "client-key.pem"), dir, serverName) + client, err := NewTLSFromTCP(clientSocket, filepath.Join(dir, "client-cert.pem"), filepath.Join(dir, "client-key.pem"), dir, serverName, password) if client != nil { client.Close() } else { @@ -120,18 +120,45 @@ func testTLSHandshake(t *testing.T, dir string, serverCert tls.Certificate, serv func TestModernServerCertificateVerification(t *testing.T) { dir, serverCert := testTLSFiles(t) - if err := testTLSHandshake(t, dir, serverCert, "localhost"); err != nil { + if err := testTLSHandshake(t, dir, serverCert, "localhost", ""); err != nil { t.Fatalf("trusted DNS name: %v", err) } - if err := testTLSHandshake(t, dir, serverCert, "[::1]"); err != nil { + if err := testTLSHandshake(t, dir, serverCert, "[::1]", ""); err != nil { t.Fatalf("trusted bracketed IPv6: %v", err) } - if err := testTLSHandshake(t, dir, serverCert, "wrong.example"); err == nil || !strings.Contains(err.Error(), "not wrong.example") { + if err := testTLSHandshake(t, dir, serverCert, "wrong.example", ""); err == nil || !strings.Contains(err.Error(), "not wrong.example") { t.Fatalf("wrong hostname should fail verification, got %v", err) } untrustedCA, _, _ := testCA(t) writeTestFile(t, filepath.Join(dir, "ca-cert.pem"), untrustedCA) - if err := testTLSHandshake(t, dir, serverCert, "localhost"); err == nil || !strings.Contains(err.Error(), "unknown authority") { + if err := testTLSHandshake(t, dir, serverCert, "localhost", ""); err == nil || !strings.Contains(err.Error(), "unknown authority") { t.Fatalf("untrusted CA should fail verification, got %v", err) } } + +func TestEncryptedClientKey(t *testing.T) { + dir, serverCert := testTLSFiles(t) + keyPath := filepath.Join(dir, "client-key.pem") + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatal(err) + } + block, _ := pem.Decode(keyPEM) + if block == nil { + t.Fatal("missing private key") + } + password := "test+ssl&pwd 123" + encrypted, err := x509.EncryptPEMBlock(rand.Reader, block.Type, block.Bytes, []byte(password), x509.PEMCipherAES256) + if err != nil { + t.Fatal(err) + } + writeTestFile(t, keyPath, pem.EncodeToMemory(encrypted)) + if err := testTLSHandshake(t, dir, serverCert, "localhost", password); err != nil { + t.Fatalf("correct ssl_pwd: %v", err) + } + for _, wrong := range []string{"", "wrong-password"} { + if err := testTLSHandshake(t, dir, serverCert, "localhost", wrong); err == nil { + t.Fatalf("ssl_pwd %q should fail", wrong) + } + } +} diff --git a/tests/integration/test_p1_connection_matrix.py b/tests/integration/test_p1_connection_matrix.py index 71ed9a7..db29741 100644 --- a/tests/integration/test_p1_connection_matrix.py +++ b/tests/integration/test_p1_connection_matrix.py @@ -335,7 +335,6 @@ def test_rwseparate_options_are_reported(conn_params): @pytest.mark.parametrize( ("option", "value"), [ - ("ssl_pwd", "test-only-password"), ("ukey_name", "nonexistent-test-ukey"), ("ukey_pin", "test-only-pin"), ], @@ -345,6 +344,11 @@ def test_security_options_do_not_silently_connect_without_support(conn_params, o dmPython.connect(**conn_params, **{option: value}) +def test_ssl_pwd_requires_ssl_path(conn_params): + with pytest.raises(dmPython.Error, match="ssl_pwd requires ssl_path"): + dmPython.connect(**conn_params, ssl_pwd="test-only-password") + + def test_ssl_path_rejects_plain_database(conn_params): with pytest.raises(dmPython.Error, match="did not negotiate encrypted SSL"): dmPython.connect(**conn_params, ssl_path="/nonexistent/dmpython-client-ssl") diff --git a/tests/ssl/test_ssl_connection.py b/tests/ssl/test_ssl_connection.py index 8bccdb0..65f429d 100644 --- a/tests/ssl/test_ssl_connection.py +++ b/tests/ssl/test_ssl_connection.py @@ -2,6 +2,7 @@ import os import shutil +import subprocess import time import dmPython @@ -71,3 +72,36 @@ def test_missing_server_certificate_pin_is_rejected(ssl_params, tmp_path): (cert_dir / "server-cert.pem").unlink() with pytest.raises(dmPython.Error, match="requires server-cert.pem pin"): dmPython.connect(**{**ssl_params, "ssl_path": str(cert_dir)}) + + +def test_encrypted_client_key_password(ssl_params, tmp_path): + cert_dir = tmp_path / "encrypted-client-key" + shutil.copytree(ssl_params["ssl_path"], cert_dir) + key_path = cert_dir / "client-key.pem" + encrypted_path = cert_dir / "encrypted-key.pem" + password = "test+ssl&pwd 123" + with dmPython.connect(**ssl_params, ssl_pwd=password) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + subprocess.run( + [ + "openssl", "rsa", "-aes256", "-traditional", "-in", str(key_path), + "-out", str(encrypted_path), "-passout", "env:DM_SSL_KEY_PWD", + ], + env={**os.environ, "DM_SSL_KEY_PWD": password}, + check=True, + capture_output=True, + text=True, + ) + encrypted_path.replace(key_path) + + options = {**ssl_params, "ssl_path": str(cert_dir)} + with pytest.raises(dmPython.Error, match="requires ssl_pwd"): + dmPython.connect(**options) + with pytest.raises(dmPython.Error, match="decrypt SSL private key|private key"): + dmPython.connect(**options, ssl_pwd="wrong-password") + with dmPython.connect(**options, ssl_pwd=password) as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,)