diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 18678f4..b613140 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -98,3 +98,75 @@ jobs: - name: Show database logs on failure if: failure() run: docker logs --tail 80 dmpython-ci-dm8 || true + + real-dm-ssl-arm: + name: Real DM8 SSL / ARM Linux / Python 3.10 + runs-on: ubuntu-24.04-arm + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.21" + cache-dependency-path: dpi_bridge/go.sum + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Start isolated DM8 and extract build headers + run: | + admin_password="DmPyA1$(openssl rand -hex 12)" + echo "::add-mask::$admin_password" + { + echo "DM_CI_ADMIN_PASSWORD=$admin_password" + echo 'DM_SSL_TEST_PASSWORD='"$admin_password" + echo 'DM_SSL_TEST_USER=SYSDBA' + echo 'DM_SSL_TEST_HOST=127.0.0.1' + echo 'DM_SSL_TEST_PORT=15239' + echo "DM_SSL_TEST_PATH=$PWD/ssl-certs" + } >> "$GITHUB_ENV" + docker pull --platform linux/arm64 \ + yhl452493373/dm8@sha256:5b9d23c04b148d5765d6077d95b64032be64daedabeef9cddc7a4216b9ad0a1a + docker run -d --user root --workdir /opt/dmdbms/bin --name dmpython-ci-dm8-ssl \ + -e SYSDBA_PWD="$admin_password" \ + -e SYSAUDITOR_PWD="$admin_password" \ + -e CHARSET=1 -e DB_NAME=DMPYSSL -e INSTANCE_NAME=DMPYSSL \ + -p 127.0.0.1:15239:5236 \ + yhl452493373/dm8@sha256:5b9d23c04b148d5765d6077d95b64032be64daedabeef9cddc7a4216b9ad0a1a + mkdir -p dpi_include ssl-certs + docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/drivers/dpi/include/. dpi_include/ + docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/bin/client_ssl/SYSDBA/. ssl-certs/ + docker cp dmpython-ci-dm8-ssl:/opt/dmdbms/bin/server_ssl/server-cert.pem ssl-certs/server-cert.pem + test -f dpi_include/DPI.h + chmod 700 ssl-certs + chmod 600 ssl-certs/* + + - name: Build driver and enable mandatory SSL + run: | + python -m pip install setuptools wheel pytest pytest-timeout + if ! python setup.py build_ext --inplace > /tmp/dmpython-ssl-build.log 2>&1; then + tail -80 /tmp/dmpython-ssl-build.log + exit 1 + fi + PYTHONPATH="$PWD" python scripts/enable_dm_ssl_ci.py + docker restart dmpython-ci-dm8-ssl + + - name: Run encrypted-connection regression + timeout-minutes: 10 + env: + TZ: Asia/Shanghai + run: python -m pytest -q tests/ssl --junitxml=pytest-dm-ssl.xml + + - name: Upload SSL regression evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: real-dm-ssl-arm-py3.10 + path: pytest-dm-ssl.xml + if-no-files-found: ignore + + - name: Show SSL database logs on failure + if: failure() + run: docker logs --tail 80 dmpython-ci-dm8-ssl || true diff --git a/README.md b/README.md index 241e400..00d366c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ For production environments, prefer the official [DamengDB/dmPython](https://git - **Supported build targets**: macOS 14+ ARM64 with CPython 3.9–3.13. Database behavior is supported only where integration tests have evidence. - **Best-effort**: Extended scenarios not currently covered by CI. - **Not guaranteed**: Production SLA commitments, vendor-certified compatibility guarantees, and closed-source component support contracts. -- **Connection security**: SSL certificate and UKey login options are not implemented by the Go bridge. Non-empty `ssl_path`, `ssl_pwd`, `ukey_name`, or `ukey_pin` now raise an error instead of silently using a regular connection. MPP and read/write separation settings are passed through, but cluster routing needs a cluster regression environment. +- **Connection security**: `ssl_path` supports encrypted DM8 connections with client certificate and key. The directory must contain `ca-cert.pem`, `client-cert.pem`, and `client-key.pem`; bundled legacy server certificates without a SAN also require an exact `server-cert.pem` pin. A plain server is rejected when `ssl_path` is set. Non-empty `ssl_pwd`, `ukey_name`, and `ukey_pin` remain unsupported. MPP and read/write separation settings are passed through, but cluster routing needs a cluster regression environment. ## Roadmap & Status diff --git a/docs/README_zh.md b/docs/README_zh.md index 22d4672..bcf40b5 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 承诺、厂商认证兼容性与闭源组件支持协议。 -- **连接安全**:Go 桥接层尚未实现 SSL 证书和 UKey 登录。非空的 `ssl_path`、`ssl_pwd`、`ukey_name`、`ukey_pin` 现在会报错;MPP 与读写分离参数已传递到底层驱动,集群路由效果仍需集群环境回归。 +- **连接安全**:`ssl_path` 支持使用客户端证书与私钥连接启用加密的 DM8。目录需包含 `ca-cert.pem`、`client-cert.pem`、`client-key.pem`;服务端证书没有 SAN 的旧版本还需提供与服务端完全一致的 `server-cert.pem`。指定 `ssl_path` 时,未协商加密的连接会报错。非空的 `ssl_pwd`、`ukey_name`、`ukey_pin` 仍不支持;MPP 与读写分离参数已传递到底层驱动,集群路由效果仍需集群环境回归。 ## 路线图与状态 diff --git a/docs/api-reference.md b/docs/api-reference.md index c8c68e8..e22a96d 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` 设为配置文件中的服务名。 - `mpp_login` 接受 `DSQL_MPP_LOGIN_GLOBAL` 或 `DSQL_MPP_LOGIN_LOCAL`;`rwseparate` 接受 `DSQL_RWSEPARATE_OFF`、`DSQL_RWSEPARATE_ON` 或 `DSQL_RWSEPARATE_ON2`,`rwseparate_percent` 范围为 0–100。这些选项在建连时传给底层驱动,当前仅验证了单机实例上的 MPP LOCAL 握手和参数传递,读写路由仍需主备环境验证。 -- 当前桥接层尚未实现与原生 DPI 语义一致的 SSL 证书和 UKey 登录;传入非空的 `ssl_path`、`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`、`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/ci.md b/docs/ci.md index 27b1ed9..3c7ac9e 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -4,6 +4,8 @@ The [data-type and connection-option matrix](test-results/2026-09-26-type-connection-matrix.md) adds 38 P1 cases. CI sets `TZ=Asia/Shanghai` during the real-database run so the `TIME` binding regression remains observable on UTC-hosted runners. The matrix records currently unverified option effects and the high-precision `DECIMAL` write defect. +A separate ARM Linux job starts DM8 with mandatory SSL and tests a verified encrypted connection on Python 3.10. It also checks paths containing spaces and `&`, rejection of a wrong or missing server certificate pin, and rejection of a plain server when `ssl_path` is requested. The repository does not upload the CI client key as an artifact. + The real-database job also exposes its disposable DM8 container to the BFILE tests. Those tests create a binary file in the container and a database directory with the temporary CI administrator credential, grant the test user read access, then remove both resources. Local runs need `DM_BFILE_TEST_CONTAINER` and `DM_CI_ADMIN_PASSWORD` to run these cases; without them, the BFILE cases are skipped. CI supplies both and treats skips as a gate failure. After all five real-database jobs pass, five macOS ARM jobs build and install wheels for CPython 3.9–3.13. They use the existing `DPI_HEADERS_TAR_B64` repository secret, so fork pull requests run the real-database matrix but skip macOS wheel builds. The required `CI gate` check accepts that documented fork exception; it requires both real-database and wheel jobs for trusted branches. Headers and image archives are not committed or uploaded as artifacts. The standalone `Integration Tests` workflow also runs the full five-version suite nightly and can be started manually. [Five-version results and release rehearsal](test-results/2026-09-25-five-python-release-rehearsal.md) record the exact scope. diff --git a/docs/test-results/2026-09-27-ssl-connection.md b/docs/test-results/2026-09-27-ssl-connection.md new file mode 100644 index 0000000..0a2ddb4 --- /dev/null +++ b/docs/test-results/2026-09-27-ssl-connection.md @@ -0,0 +1,22 @@ +# SSL connection regression (2026-09-27) + +## Local ARM verification + +- Official DM8 ARM container with `ENABLE_ENCRYPT=1`: four SSL tests passed. +- The exact ARM development image pinned by CI, started with working directory + `/opt/dmdbms/bin` and `ENABLE_ENCRYPT=1`: the same four tests passed. +- The image fails at startup with `SSL encrypt fail!` when left at its default + working directory, `/home/dmdba`; the SSL CI job sets the working directory. +- The four checks cover encrypted login and query, an SSL path containing spaces + and `&`, a wrong server-certificate pin, and a missing pin. +- Full real-database suite on the GB18030 instance: 188 passed, 6 deselected. + The UTF8 instance lacks the admin credential required by four container-based + cases; 184 passed, 4 skipped, 6 deselected there. GitHub CI supplies the + required credential for both database encodings. + +## Remaining scope + +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. diff --git a/dpi_bridge/dpi_conn.go b/dpi_bridge/dpi_conn.go index fc298dc..530cddf 100644 --- a/dpi_bridge/dpi_conn.go +++ b/dpi_bridge/dpi_conn.go @@ -61,6 +61,7 @@ type connHandle struct { connTimeout int appName string compressMsg int + sslPath string svcPath string mppLogin int rwSeparate int @@ -244,10 +245,22 @@ func dpi_set_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, valLen C.sd conn.lastErr = &diagInfo{errorCode: -1, message: "use_stmt_pool is not supported by this bridge"} return DSQL_ERROR } - case DSQL_ATTR_SSL_PATH, DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN: + case DSQL_ATTR_SSL_PATH: + if conn.conn != nil { + conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_path can only be set before login"} + return DSQL_ERROR + } + if val == nil { + conn.sslPath = "" + } else if valLen > 0 { + conn.sslPath = C.GoStringN((*C.char)(val), C.int(valLen)) + } else { + conn.sslPath = C.GoString((*C.char)(val)) + } + case DSQL_ATTR_SSL_PWD, DSQL_ATTR_UKEY_NAME, DSQL_ATTR_UKEY_PIN: if val != nil && C.GoString((*C.char)(val)) != "" { name := map[int32]string{ - DSQL_ATTR_SSL_PATH: "ssl_path", DSQL_ATTR_SSL_PWD: "ssl_pwd", + 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"} @@ -363,6 +376,11 @@ func dpi_get_con_attr(hcon C.dhcon, attrID C.sdint4, val C.dpointer, bufLen C.sd if valLen != nil { *valLen = C.sdint4(n) } + case DSQL_ATTR_SSL_PATH: + n := cStringLen((*C.sdbyte)(val), int(bufLen), conn.sslPath) + if valLen != nil { + *valLen = C.sdint4(n) + } case DSQL_ATTR_CONNECTION_DEAD: dead := C.sdint4(0) // DSQL_CD_FALSE if conn.conn == nil { @@ -481,6 +499,9 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI if conn.compressMsg >= 0 { params = append(params, "compress="+strconv.Itoa(conn.compressMsg)) } + if conn.sslPath != "" { + params = append(params, "sslFilesPath="+url.QueryEscape(conn.sslPath)) + } if conn.svcPath != "" { params = append(params, "svcConfPath="+url.QueryEscape(filepath.Join(conn.svcPath, "dm_svc.conf"))) } @@ -542,6 +563,11 @@ func dpi_login(hcon C.dhcon, svr *C.sdbyte, user *C.sdbyte, pwd *C.sdbyte) C.DPI } return DSQL_ERROR } + if conn.sslPath != "" && dmConn.SSLMode() != 1 { + db.Close() + conn.lastErr = &diagInfo{errorCode: -1, message: "ssl_path requested, but the server did not negotiate encrypted SSL"} + return DSQL_ERROR + } conn.db = db conn.conn = dmConn diff --git a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md index 4f75e42..3c47bc6 100644 --- a/dpi_bridge/third_party/chunanyong_dm/PATCHES.md +++ b/dpi_bridge/third_party/chunanyong_dm/PATCHES.md @@ -52,6 +52,18 @@ - Regression: `test_compress_msg_is_applied` connects with compression both disabled and enabled against a real DM8 instance. +## Patch: verified SSL connections + +- Files: `a.go`, `n.go`, `bridge_options.go`, `security/zzi.go` +- Resolve the client key from `sslFilesPath` and decode escaped SSL path values. +- Verify modern server certificates with the configured CA and hostname. For + DM8's bundled legacy certificate without SAN, require an exact server + certificate pin and reject expired pins. +- Expose the negotiated SSL mode so the DPI bridge rejects plain connections + when the caller requests `ssl_path`. +- Regression: `tests/ssl/test_ssl_connection.py` uses a real SSL-enabled DM8 + instance, including wrong and missing pins. + ## 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 cd9913e..d0f55ac 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.user); 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_689 != nil { return } if !dm_build_688 { diff --git a/dpi_bridge/third_party/chunanyong_dm/bridge_options.go b/dpi_bridge/third_party/chunanyong_dm/bridge_options.go index 6d29463..cd2f171 100644 --- a/dpi_bridge/third_party/chunanyong_dm/bridge_options.go +++ b/dpi_bridge/third_party/chunanyong_dm/bridge_options.go @@ -7,3 +7,8 @@ func (dc *DmConnection) CompressionMode() int { } return dc.dmConnector.compress } + +// SSLMode reports the server-negotiated TLS mode (1 encrypts the session). +func (dc *DmConnection) SSLMode() int { + return dc.sslEncrypt +} diff --git a/dpi_bridge/third_party/chunanyong_dm/n.go b/dpi_bridge/third_party/chunanyong_dm/n.go index c572bc9..0c818de 100644 --- a/dpi_bridge/third_party/chunanyong_dm/n.go +++ b/dpi_bridge/third_party/chunanyong_dm/n.go @@ -602,7 +602,7 @@ func (c *DmConnector) setAttributes(props *Properties) error { } c.sslKeyPath = props.GetTrimString(SslKeyPathKey, c.sslKeyPath) if c.sslKeyPath == "" && c.sslFilesPath != "" { - c.sslKeyPath = filepath.Join(c.sslKeyPath, "client-key.pem") + c.sslKeyPath = filepath.Join(c.sslFilesPath, "client-key.pem") } c.kerberosLoginConfPath = props.GetTrimString(KerberosLoginConfPathKey, c.kerberosLoginConfPath) @@ -760,7 +760,8 @@ func (c *DmConnector) parseDSN(dsn string) (*Properties, string, string, error) kv := strings.SplitN(kvString, "=", 2) if kv != nil && len(kv) > 1 { value := kv[1] - if kv[0] == AppNameKey || kv[0] == "svcConfPath" { + if kv[0] == AppNameKey || kv[0] == "svcConfPath" || + kv[0] == SslFilesPathKey || kv[0] == SslCertPathKey || kv[0] == SslKeyPathKey { 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 bfc3f1c..045d168 100644 --- a/dpi_bridge/third_party/chunanyong_dm/security/zzi.go +++ b/dpi_bridge/third_party/chunanyong_dm/security/zzi.go @@ -6,28 +6,80 @@ package security import ( + "bytes" "crypto/tls" + "crypto/x509" + "encoding/pem" "errors" + "fmt" "net" + "os" + "path/filepath" "sync" + "time" ) -//var dmHome = flag.String("DM_HOME", "", "Where DMDB installed") +// var dmHome = flag.String("DM_HOME", "", "Where DMDB installed") var flagLock = sync.Mutex{} -func NewTLSFromTCP(conn net.Conn, sslCertPath string, sslKeyPath string, user string) (*tls.Conn, error) { - if sslCertPath == "" && sslKeyPath == "" { - // 用户必须手动指定ssl文件和签名(.cert文件) - return nil, errors.New("sslCertPath and sslKeyPath can not be empty!") - +func NewTLSFromTCP(conn net.Conn, sslCertPath, sslKeyPath, sslFilesPath, serverName string) (*tls.Conn, error) { + if sslCertPath == "" || sslKeyPath == "" || sslFilesPath == "" { + return nil, errors.New("SSL certificate, key, and CA directory are required") } - cer, err := tls.LoadX509KeyPair(sslCertPath, sslKeyPath) + cert, err := tls.LoadX509KeyPair(sslCertPath, sslKeyPath) if err != nil { return nil, err } + caPEM, err := os.ReadFile(filepath.Join(sslFilesPath, "ca-cert.pem")) + if err != nil { + return nil, err + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(caPEM) { + return nil, errors.New("SSL CA file has no valid certificates") + } conf := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: serverName, + Certificates: []tls.Certificate{cert}, + // DM's bundled server certificate has no SAN and uses SHA1. Require an + // exact certificate pin for that legacy case; modern certs use CA and SAN. InsecureSkipVerify: true, - Certificates: []tls.Certificate{cer}, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("SSL server did not provide a certificate") + } + leaf := state.PeerCertificates[0] + if len(leaf.DNSNames) == 0 && len(leaf.IPAddresses) == 0 { + pinnedPEM, err := os.ReadFile(filepath.Join(sslFilesPath, "server-cert.pem")) + if err != nil { + return fmt.Errorf("legacy SSL server certificate requires server-cert.pem pin: %w", err) + } + block, _ := pem.Decode(pinnedPEM) + if block == nil || !bytes.Equal(leaf.Raw, block.Bytes) { + return errors.New("SSL server certificate does not match server-cert.pem pin") + } + now := time.Now() + if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return errors.New("SSL server certificate pin is expired or not yet valid") + } + if leaf.IsCA || (leaf.Subject.CommonName != "server" && leaf.Subject.CommonName != serverName) { + return fmt.Errorf("SSL server certificate has unexpected CN %q", leaf.Subject.CommonName) + } + return nil + } + intermediates := x509.NewCertPool() + for _, intermediate := range state.PeerCertificates[1:] { + intermediates.AddCert(intermediate) + } + if _, err := leaf.Verify(x509.VerifyOptions{ + Roots: roots, Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }); err != nil { + return err + } + return leaf.VerifyHostname(serverName) + }, } tlsConn := tls.Client(conn, conf) if err := tlsConn.Handshake(); err != nil { diff --git a/scripts/enable_dm_ssl_ci.py b/scripts/enable_dm_ssl_ci.py new file mode 100644 index 0000000..1908bca --- /dev/null +++ b/scripts/enable_dm_ssl_ci.py @@ -0,0 +1,29 @@ +"""Enable mandatory SSL on the isolated DM8 CI instance before its restart.""" + +import os +import time + +import dmPython + + +deadline = time.monotonic() + 240 +while True: + try: + conn = dmPython.connect( + user="SYSDBA", + password=os.environ["DM_CI_ADMIN_PASSWORD"], + server=os.environ["DM_SSL_TEST_HOST"], + port=int(os.environ["DM_SSL_TEST_PORT"]), + ) + break + except dmPython.Error: + if time.monotonic() >= deadline: + raise RuntimeError("DM8 did not become ready within 240 seconds") from None + time.sleep(3) + +try: + with conn.cursor() as cur: + cur.execute("SP_SET_PARA_VALUE(2, 'ENABLE_ENCRYPT', 1)") + conn.commit() +finally: + conn.close() diff --git a/tests/integration/test_p1_connection_matrix.py b/tests/integration/test_p1_connection_matrix.py index c6e9495..71ed9a7 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_path", "/nonexistent/dmpython-client-ssl"), ("ssl_pwd", "test-only-password"), ("ukey_name", "nonexistent-test-ukey"), ("ukey_pin", "test-only-pin"), @@ -346,6 +345,11 @@ def test_security_options_do_not_silently_connect_without_support(conn_params, o dmPython.connect(**conn_params, **{option: value}) +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") + + @pytest.mark.parametrize( ("option", "value"), [ diff --git a/tests/ssl/test_ssl_connection.py b/tests/ssl/test_ssl_connection.py new file mode 100644 index 0000000..8bccdb0 --- /dev/null +++ b/tests/ssl/test_ssl_connection.py @@ -0,0 +1,73 @@ +"""Real SSL handshake checks against a dedicated encrypted DM8 instance.""" + +import os +import shutil +import time + +import dmPython +import pytest + + +@pytest.fixture(scope="module") +def ssl_params(): + names = ( + "DM_SSL_TEST_HOST", + "DM_SSL_TEST_PORT", + "DM_SSL_TEST_USER", + "DM_SSL_TEST_PASSWORD", + "DM_SSL_TEST_PATH", + ) + missing = [name for name in names if not os.getenv(name)] + if missing: + pytest.fail(f"SSL database environment is incomplete: {', '.join(missing)}") + params = { + "server": os.environ["DM_SSL_TEST_HOST"], + "port": int(os.environ["DM_SSL_TEST_PORT"]), + "user": os.environ["DM_SSL_TEST_USER"], + "password": os.environ["DM_SSL_TEST_PASSWORD"], + "ssl_path": os.environ["DM_SSL_TEST_PATH"], + } + deadline = time.monotonic() + 180 + while True: + try: + conn = dmPython.connect(**params) + conn.close() + return params + except dmPython.Error: + if time.monotonic() >= deadline: + pytest.fail("SSL database did not become ready within 180 seconds") + time.sleep(3) + + +def test_encrypted_connection_and_query(ssl_params): + with dmPython.connect(**ssl_params) as conn: + assert conn.ssl_path == ssl_params["ssl_path"] + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +def test_ssl_path_with_spaces_and_ampersand(ssl_params, tmp_path): + cert_dir = tmp_path / "ssl files & certs" + shutil.copytree(ssl_params["ssl_path"], cert_dir) + with dmPython.connect(**{**ssl_params, "ssl_path": str(cert_dir)}) as conn: + assert conn.ssl_path == str(cert_dir) + with conn.cursor() as cur: + cur.execute("SELECT 1") + assert cur.fetchone() == (1,) + + +def test_wrong_server_certificate_pin_is_rejected(ssl_params, tmp_path): + cert_dir = tmp_path / "wrong-pin" + shutil.copytree(ssl_params["ssl_path"], cert_dir) + shutil.copyfile(cert_dir / "client-cert.pem", cert_dir / "server-cert.pem") + with pytest.raises(dmPython.Error, match="does not match server-cert.pem pin"): + dmPython.connect(**{**ssl_params, "ssl_path": str(cert_dir)}) + + +def test_missing_server_certificate_pin_is_rejected(ssl_params, tmp_path): + cert_dir = tmp_path / "missing-pin" + shutil.copytree(ssl_params["ssl_path"], cert_dir) + (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)})