Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 全局和本地登录已在本机两节点集群验证分布式读写。

## 路线图与状态
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
Expand Down
9 changes: 8 additions & 1 deletion docs/test-results/2026-09-27-ssl-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
23 changes: 21 additions & 2 deletions dpi_bridge/dpi_conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type connHandle struct {
appName string
compressMsg int
sslPath string
sslPassword string
svcPath string
mppLogin int
rwSeparate int
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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")))
}
Expand Down
10 changes: 10 additions & 0 deletions dpi_bridge/third_party/chunanyong_dm/PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion dpi_bridge/third_party/chunanyong_dm/a.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions dpi_bridge/third_party/chunanyong_dm/n.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const (
SslCertPathKey = "sslCertPath"
SslKeyPathKey = "sslKeyPath"
SslFilesPathKey = "sslFilesPath"
SslKeyPasswordKey = "sslKeyPassword"
KerberosLoginConfPathKey = "kerberosLoginConfPath"
UKeyNameKey = "uKeyName"
UKeyPinKey = "uKeyPin"
Expand Down Expand Up @@ -390,7 +391,8 @@ type DmConnector struct {

sslKeyPath string

sslFilesPath string
sslFilesPath string
sslKeyPassword string

kerberosLoginConfPath string

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
38 changes: 36 additions & 2 deletions dpi_bridge/third_party/chunanyong_dm/security/zzi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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}))
}
39 changes: 33 additions & 6 deletions dpi_bridge/third_party/chunanyong_dm/security/zzi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
6 changes: 5 additions & 1 deletion tests/integration/test_p1_connection_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
],
Expand All @@ -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")
Expand Down
34 changes: 34 additions & 0 deletions tests/ssl/test_ssl_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import shutil
import subprocess
import time

import dmPython
Expand Down Expand Up @@ -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,)
Loading