From 2fc15bb6fd692f2544547b95ecc9d36e46a395e6 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Mon, 29 Jun 2026 20:41:26 +0900 Subject: [PATCH 01/11] Add Direct SSL Negotiation --- .github/workflows/test.yml | 8 +- README.md | 5 + async_postgres/pg_connection/dsn.nim | 13 ++ async_postgres/pg_connection/ssl.nim | 273 +++++++++++++++---------- async_postgres/pg_connection/types.nim | 6 + tests/test_dsn.nim | 23 +++ tests/test_ssl.nim | 91 +++++++++ 7 files changed, 305 insertions(+), 114 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40c2108..1c12450 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,9 @@ jobs: os: - 'ubuntu-latest' nim-version: - - '2.2.4' + #- '2.2.4' - 'stable' - - 'devel' + #- 'devel' name: Tests on ${{ matrix.nim-version }} (${{ matrix.os }}) steps: @@ -74,7 +74,9 @@ jobs: - name: Install chronos id: install-chronos - run: nimble install chronos -y + # Direct SSL negotiation needs chronos' ALPN support, which is only in + # the development HEAD (not yet in a tagged release). + run: nimble install -y "https://github.com/status-im/nim-chronos@#head" background: true - name: Wait setup diff --git a/README.md b/README.md index f6ed321..b1e9f2c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Async PostgreSQL client in Nim. - Connection pooling with health checks and maintenance (broken connections discarded on acquire/release) - Pool cluster with read replica routing - SSL/TLS support (disable, allow, prefer, require, verify-ca, verify-full) +- `sslnegotiation` mode (postgres, direct) for Direct SSL connections (PostgreSQL 17+) - MD5, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS authentication - `channel_binding` policy (disable, prefer, require) to harden SCRAM against downgrade - DSN connection string parsing @@ -129,6 +130,10 @@ SSL backend differs by async backend: - asyncdispatch: OpenSSL (requires `-d:ssl`) - chronos: BearSSL (via [nim-bearssl](https://github.com/status-im/nim-bearssl); TLS 1.2 only due to BearSSL limitation) +Direct SSL negotiation (`sslnegotiation=direct`) requires `sslmode=require` or +stronger. On the chronos backend it needs chronos' ALPN support, currently only +in the development HEAD (`nimble install "https://github.com/status-im/nim-chronos@#head"`). + ## Examples The [examples](examples/) directory contains runnable samples: diff --git a/async_postgres/pg_connection/dsn.nim b/async_postgres/pg_connection/dsn.nim index 787eacc..3e6561e 100644 --- a/async_postgres/pg_connection/dsn.nim +++ b/async_postgres/pg_connection/dsn.nim @@ -40,6 +40,15 @@ proc parseChannelBindingMode*(s: string): ChannelBindingMode = else: raise newException(PgError, "Invalid channel_binding: " & s) +proc parseSslNegotiation*(s: string): SslNegotiation = + case s + of "postgres": + sslnPostgres + of "direct": + sslnDirect + else: + raise newException(PgError, "Invalid sslnegotiation: " & s) + proc parseAuthMethod*(s: string): AuthMethod = case s of "none": @@ -174,6 +183,8 @@ proc applyParam*(result: var ConnConfig, key, val: string) = result.sslMode = parseSslMode(val) of "channel_binding": result.channelBinding = parseChannelBindingMode(val) + of "sslnegotiation": + result.sslNegotiation = parseSslNegotiation(val) of "require_auth": result.requireAuth = parseRequireAuth(val) of "application_name": @@ -476,6 +487,7 @@ proc initConnConfig*( password = "", database = "", sslMode = sslPrefer, + sslNegotiation = sslnPostgres, sslRootCert = "", sslSni = true, channelBinding = cbPrefer, @@ -503,6 +515,7 @@ proc initConnConfig*( password: password, database: database, sslMode: sslMode, + sslNegotiation: sslNegotiation, sslRootCert: sslRootCert, sslSni: sslSni, channelBinding: channelBinding, diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index 812eda0..aa0e9f9 100644 --- a/async_postgres/pg_connection/ssl.nim +++ b/async_postgres/pg_connection/ssl.nim @@ -1,7 +1,10 @@ ## TLS/SSL negotiation for PostgreSQL connections. ## -## Implements the libpq-compatible SSLRequest handshake and the subsequent -## TLS handshake under both async backends: +## Implements both libpq negotiation styles: the traditional SSLRequest +## handshake (`sslnegotiation=postgres`) and Direct SSL, which starts TLS +## immediately and requires the "postgresql" ALPN protocol +## (`sslnegotiation=direct`, PostgreSQL 17+). The TLS handshake itself is shared +## by `establishTls` under both async backends: ## ## - **chronos**: BearSSL-based TLS via `chronos/streams/tlsstream`, with ## custom trust anchor parsing (`parseTrustAnchors`) and X.509 capture for @@ -24,6 +27,10 @@ elif hasAsyncDispatch: when defined(ssl): import std/[dynlib, openssl, tempfiles, os] +const PgAlpnProtocol = "postgresql" + ## ALPN protocol name a Direct SSL connection must negotiate (PostgreSQL 17+). + ## libpq sends and requires the same name for `sslnegotiation=direct`. + when hasAsyncDispatch and defined(ssl): # On asyncdispatch `conn.socket` is an `AsyncSocket`, so `wrapConnectedSocket` # resolves to `std/asyncnet`'s overload, which only sets SNI — it never matches @@ -219,10 +226,146 @@ proc sniName*(sslHost: string, sslSni: bool): string = return "" sslHost +proc establishTls( + conn: PgConnection, config: ConnConfig, sslHost: string, direct: bool +) {.async.} = + ## Run the TLS handshake over `conn`'s transport and wire up the encrypted + ## reader/writer. Shared by traditional (post-`SSLRequest`) and Direct SSL + ## negotiation. When `direct` is true the client offers the "postgresql" ALPN + ## protocol and requires the server to select it, matching libpq's + ## `sslnegotiation=direct` behaviour (PostgreSQL 17+). + when hasChronos: + conn.baseReader = newAsyncStreamReader(conn.transport) + conn.baseWriter = newAsyncStreamWriter(conn.transport) + + let flags = + case config.sslMode + of sslVerifyFull: + {} + of sslVerifyCa: + {TLSFlags.NoVerifyServerName} + else: + {TLSFlags.NoVerifyHost, TLSFlags.NoVerifyServerName} + + # verify-full: sslHost drives BearSSL's X509 name check; else honor sslSni. + let serverName = + if config.sslMode == sslVerifyFull: + sslHost + else: + sniName(sslHost, config.sslSni) + let alpn = + if direct: + @[PgAlpnProtocol] + else: + @[] + + if config.sslMode in {sslVerifyCa, sslVerifyFull}: + let parsed = parseTrustAnchors(config.sslRootCert) + conn.trustAnchorBufs = parsed.backing + # Must outlive TLS session (see parseTrustAnchors doc) + conn.tlsStream = newTLSClientAsyncStream( + conn.baseReader, + conn.baseWriter, + serverName, + flags = flags, + minVersion = TLSVersion.TLS12, + maxVersion = TLSVersion.TLS12, + trustAnchors = parsed.store, + alpnProtocols = alpn, + ) + else: + # NoVerifyHost is set, so trust anchors are ignored regardless. + conn.tlsStream = newTLSClientAsyncStream( + conn.baseReader, + conn.baseWriter, + serverName, + flags = flags, + minVersion = TLSVersion.TLS12, + maxVersion = TLSVersion.TLS12, + alpnProtocols = alpn, + ) + installX509Capture( + conn.x509Capture, conn.tlsStream.ccontext.eng, addr conn.serverCertDer + ) + await conn.tlsStream.handshake() + if direct and conn.tlsStream.getSelectedAlpnProtocol() != PgAlpnProtocol: + raise newException( + PgConnectionError, + "direct SSL connection established without ALPN: the server does not " & + "support sslnegotiation=direct (requires PostgreSQL 17+)", + ) + conn.reader = conn.tlsStream.reader + conn.writer = conn.tlsStream.writer + conn.sslEnabled = true + elif hasAsyncDispatch: + when defined(ssl): + let verifyMode = + case config.sslMode + of sslVerifyCa, sslVerifyFull: SslCVerifyMode.CVerifyPeer + else: SslCVerifyMode.CVerifyNone + + var ctx: SslContext + var tmpPath: string + if config.sslMode in {sslVerifyCa, sslVerifyFull}: + let (tmpFile, tp) = createTempFile("pg_ca_", ".pem") + tmpPath = tp + try: + tmpFile.write(config.sslRootCert) + tmpFile.close() + ctx = newContext(verifyMode = verifyMode, caFile = tmpPath) + except: + removeFile(tmpPath) + raise + else: + ctx = newContext(verifyMode = verifyMode) + + if direct: + # Advertise "postgresql" ALPN: 1-byte length prefix + protocol name. + const alpnProto = "\x0a" & PgAlpnProtocol + discard + SSL_CTX_set_alpn_protos(ctx.context, alpnProto.cstring, cuint(alpnProto.len)) + + try: + let hostname = sniName(sslHost, config.sslSni) + wrapConnectedSocket(ctx, conn.socket, handshakeAsClient, hostname) + # asyncnet skips name matching; make OpenSSL enforce it during handshake. + if config.sslMode == sslVerifyFull: + enforceVerifyFullIdentity(conn.socket.sslHandle, sslHost) + # Drive handshake so peer cert is populated before SCRAM channel binding. + await driveTlsHandshake(conn.socket) + conn.sslEnabled = true + # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. + # If unavailable, cbPrefer will silently fall back to SCRAM-SHA-256 — + # warn the operator so the loss of channel binding is observable. + # (cbRequire is enforced in selectScramMechanism.) + let peerCert = SSL_get_peer_certificate(conn.socket.sslHandle) + if peerCert != nil: + try: + let derStr = i2d_X509(peerCert) + if derStr.len > 0: + conn.serverCertDer = newSeq[byte](derStr.len) + for i in 0 ..< derStr.len: + conn.serverCertDer[i] = byte(derStr[i]) + else: + stderr.writeLine "pg_connection: server certificate DER encoding is empty; SCRAM-SHA-256-PLUS channel binding unavailable" + finally: + X509_free(peerCert) + else: + stderr.writeLine "pg_connection: server certificate unavailable; SCRAM-SHA-256-PLUS channel binding unavailable" + finally: + if tmpPath.len > 0: + removeFile(tmpPath) + else: + raise + newException(PgConnectionError, "SSL support requires compiling with -d:ssl") + proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.async.} = - ## Send SSLRequest and negotiate TLS if server accepts. - ## `sslHost` is the host *name* the server certificate is verified against - ## (the entry's `host`, never its `hostaddr` — libpq semantics). + ## Negotiate TLS for the connection. With `sslnegotiation=postgres` (default) + ## this sends an `SSLRequest` and starts TLS only if the server accepts; + ## with `sslnegotiation=direct` it starts TLS immediately without the + ## round-trip (PostgreSQL 17+). `sslHost` is the host *name* the server + ## certificate is verified against (the entry's `host`, never its `hostaddr` — + ## libpq semantics). if config.sslMode in {sslVerifyCa, sslVerifyFull} and config.sslRootCert.len == 0: # Both backends silently fall back to a Web PKI store (chronos: # MozillaTrustAnchors, std/net: OS CA bundle) — for verify-ca that also @@ -236,6 +379,19 @@ proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.as raise newException( PgConnectionError, "A host name must be specified for a verified SSL connection" ) + + if config.sslNegotiation == sslnDirect: + # Direct SSL skips the SSLRequest probe, so there is no plaintext path to + # fall back to. libpq rejects weak sslmodes here for the same reason; SSL + # must actually be required. + if config.sslMode notin {sslRequire, sslVerifyCa, sslVerifyFull}: + raise newException( + PgConnectionError, + "sslnegotiation=direct requires sslmode=require, verify-ca, or verify-full", + ) + await establishTls(conn, config, sslHost, direct = true) + return + let sslReq = encodeSSLRequest() var respChar: char var extraBytesBuffered = false @@ -281,112 +437,7 @@ proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.as PgConnectionError, "Received unencrypted data after SSL response (possible man-in-the-middle)", ) - when hasChronos: - conn.baseReader = newAsyncStreamReader(conn.transport) - conn.baseWriter = newAsyncStreamWriter(conn.transport) - - let flags = - case config.sslMode - of sslVerifyFull: - {} - of sslVerifyCa: - {TLSFlags.NoVerifyServerName} - else: - {TLSFlags.NoVerifyHost, TLSFlags.NoVerifyServerName} - - # BearSSL's `serverName` doubles as SNI wire value and X509 name check - # input. Under verify-full it must be `sslHost` for BearSSL to verify; - # other modes honor sslSni and RFC 6066 IP-literal suppression. - let serverName = - if config.sslMode == sslVerifyFull: - sslHost - else: - sniName(sslHost, config.sslSni) - - if config.sslMode in {sslVerifyCa, sslVerifyFull}: - let parsed = parseTrustAnchors(config.sslRootCert) - conn.trustAnchorBufs = parsed.backing - # Must outlive TLS session (see parseTrustAnchors doc) - conn.tlsStream = newTLSClientAsyncStream( - conn.baseReader, - conn.baseWriter, - serverName, - flags = flags, - minVersion = TLSVersion.TLS12, - maxVersion = TLSVersion.TLS12, - trustAnchors = parsed.store, - ) - else: - # NoVerifyHost is set, so trust anchors are ignored regardless. - conn.tlsStream = newTLSClientAsyncStream( - conn.baseReader, - conn.baseWriter, - serverName, - flags = flags, - minVersion = TLSVersion.TLS12, - maxVersion = TLSVersion.TLS12, - ) - installX509Capture( - conn.x509Capture, conn.tlsStream.ccontext.eng, addr conn.serverCertDer - ) - await conn.tlsStream.handshake() - conn.reader = conn.tlsStream.reader - conn.writer = conn.tlsStream.writer - conn.sslEnabled = true - elif hasAsyncDispatch: - when defined(ssl): - var ctx: SslContext - var tmpPath: string - if config.sslMode in {sslVerifyCa, sslVerifyFull}: - let (tmpFile, tp) = createTempFile("pg_ca_", ".pem") - tmpPath = tp - try: - tmpFile.write(config.sslRootCert) - tmpFile.close() - ctx = newContext(verifyMode = CVerifyPeer, caFile = tmpPath) - except: - removeFile(tmpPath) - raise - else: - ctx = newContext(verifyMode = CVerifyNone) - - try: - let hostname = sniName(sslHost, config.sslSni) - wrapConnectedSocket(ctx, conn.socket, handshakeAsClient, hostname) - # asyncnet does no name matching and defers the handshake, so have - # OpenSSL enforce the hostname/IP match itself during that handshake. - if config.sslMode == sslVerifyFull: - enforceVerifyFullIdentity(conn.socket.sslHandle, sslHost) - # Drive the handshake here rather than letting the first application - # send/recv trigger it: the peer cert is required *before* SCRAM to - # decide channel binding, and OpenSSL only populates it once the - # handshake completes. - await driveTlsHandshake(conn.socket) - conn.sslEnabled = true - # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. - # If unavailable, cbPrefer will silently fall back to SCRAM-SHA-256 — - # warn the operator so the loss of channel binding is observable. - # (cbRequire is enforced in selectScramMechanism.) - let peerCert = SSL_get_peer_certificate(conn.socket.sslHandle) - if peerCert != nil: - try: - let derStr = i2d_X509(peerCert) - if derStr.len > 0: - conn.serverCertDer = newSeq[byte](derStr.len) - for i in 0 ..< derStr.len: - conn.serverCertDer[i] = byte(derStr[i]) - else: - stderr.writeLine "pg_connection: server certificate DER encoding is empty; SCRAM-SHA-256-PLUS channel binding unavailable" - finally: - X509_free(peerCert) - else: - stderr.writeLine "pg_connection: server certificate unavailable; SCRAM-SHA-256-PLUS channel binding unavailable" - finally: - if tmpPath.len > 0: - removeFile(tmpPath) - else: - raise - newException(PgConnectionError, "SSL support requires compiling with -d:ssl") + await establishTls(conn, config, sslHost, direct = false) of 'N': if config.sslMode in {sslRequire, sslVerifyCa, sslVerifyFull}: raise newException(PgConnectionError, "Server does not support SSL") diff --git a/async_postgres/pg_connection/types.nim b/async_postgres/pg_connection/types.nim index 1bf122d..1b0bc49 100644 --- a/async_postgres/pg_connection/types.nim +++ b/async_postgres/pg_connection/types.nim @@ -60,6 +60,11 @@ type sslVerifyCa ## Require SSL + verify CA chain (no hostname verification) sslVerifyFull ## Require SSL + verify CA chain and hostname + SslNegotiation* = enum + ## SSL negotiation method for the connection. + sslnPostgres ## Traditional SSLRequest negotiation (default) + sslnDirect ## Direct SSL: start TLS immediately without SSLRequest (PostgreSQL 17+) + ChannelBindingMode* = enum ## SCRAM channel binding policy (libpq-compatible). cbPrefer ## Use SCRAM-SHA-256-PLUS when SSL and server support it (default). @@ -115,6 +120,7 @@ type ## SSL/TLS negotiation mode. `parseDsn` and `initConnConfig` default this ## to `sslPrefer` (libpq parity); a raw zero-initialized `ConnConfig` has ## `sslDisable`. + sslNegotiation*: SslNegotiation ## SSL negotiation method (default: sslnPostgres) sslRootCert*: string ## PEM-encoded CA certificate(s) for sslVerifyCa/sslVerifyFull sslSni*: bool ## Send TLS SNI extension during the handshake (libpq `sslsni`, default diff --git a/tests/test_dsn.nim b/tests/test_dsn.nim index a41e7cd..c28f757 100644 --- a/tests/test_dsn.nim +++ b/tests/test_dsn.nim @@ -184,6 +184,22 @@ suite "parseDsn": expect PgError: discard parseDsn("postgresql://host/db?channel_binding=bogus") + test "query param sslnegotiation": + check parseDsn("postgresql://host/db?sslnegotiation=postgres").sslNegotiation == + sslnPostgres + check parseDsn("postgresql://host/db?sslnegotiation=direct").sslNegotiation == + sslnDirect + + test "sslnegotiation default is postgres": + check parseDsn("postgresql://host/db").sslNegotiation == sslnPostgres + + test "ConnConfig zero init has sslnPostgres": + check ConnConfig().sslNegotiation == sslnPostgres + + test "error: invalid sslnegotiation": + expect PgError: + discard parseDsn("postgresql://host/db?sslnegotiation=bogus") + test "require_auth default is empty set": let cfg = parseDsn("postgresql://host/db") check cfg.requireAuth == {} @@ -848,6 +864,13 @@ suite "parseDsn keyword=value": expect PgError: discard parseDsn("host=h channel_binding=bogus") + test "sslnegotiation parameter": + check parseDsn("host=h sslnegotiation=direct").sslNegotiation == sslnDirect + + test "error: invalid sslnegotiation": + expect PgError: + discard parseDsn("host=h sslnegotiation=bogus") + test "error: invalid connect_timeout": expect PgError: discard parseDsn("host=h connect_timeout=abc") diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 553fbb1..358ce83 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -769,6 +769,97 @@ suite "SSL negotiation - sslDisable": check connState == csReady check connSslEnabled == false +suite "Direct SSL negotiation": + test "sslnegotiation=direct rejects weak sslmode before any bytes are sent": + var raised = false + var errMentionsDirect = false + var bytesFromClient = -1 + + proc testBody() {.async.} = + let ms = startMockServer() + + proc serverHandler() {.async.} = + let st = await ms.accept() + try: + # The client must reject the weak sslmode locally and never send the + # SSLRequest or a ClientHello; the read returns 0 once it closes. + let data = await readN(st, 1) + bytesFromClient = data.len + except CatchableError: + bytesFromClient = 0 + await closeClient(st) + + let serverFut = serverHandler() + + let config = ConnConfig( + host: "127.0.0.1", + port: ms.port, + user: "test", + database: "test", + sslMode: sslPrefer, + sslNegotiation: sslnDirect, + ) + + try: + let conn = await connect(config) + await conn.close() + except PgError as e: + raised = true + errMentionsDirect = "sslnegotiation=direct" in e.msg + + await serverFut + await closeServer(ms) + + waitFor testBody() + check raised + check errMentionsDirect + check bytesFromClient == 0 + + test "sslnegotiation=direct starts TLS immediately without an SSLRequest": + var raised = false + var firstByte: int = -1 + + proc testBody() {.async.} = + let ms = startMockServer() + + proc serverHandler() {.async.} = + let st = await ms.accept() + try: + # Direct SSL skips the 8-byte SSLRequest (whose first byte is 0x00) and + # opens with a TLS handshake record (content type 0x16). Capture the + # opening byte, then drop the connection so the handshake fails fast. + let data = await readN(st, 1) + firstByte = int(data[0]) + except CatchableError: + discard + await closeClient(st) + + let serverFut = serverHandler() + + let config = ConnConfig( + host: "127.0.0.1", + port: ms.port, + user: "test", + database: "test", + sslMode: sslRequire, + sslNegotiation: sslnDirect, + ) + + try: + let conn = await connect(config) + await conn.close() + except PgError: + # The dumb mock cannot complete the TLS handshake, so connect fails after + # the ClientHello is observed — exactly what this test inspects. + raised = true + + await serverFut + await closeServer(ms) + + waitFor testBody() + check firstByte == 0x16 + check raised + proc sendAuthSasl(client: MockClient, mechanisms: seq[string]): Future[void] {.async.} = var body: seq[byte] = @[] body.addInt32(10) # AuthenticationSASL From e9f05efcc2a9b7e764ba69292f80d0e2015e27e9 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sat, 18 Jul 2026 18:34:21 +0900 Subject: [PATCH 02/11] fix --- .github/workflows/test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c12450..439ce95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,9 +74,7 @@ jobs: - name: Install chronos id: install-chronos - # Direct SSL negotiation needs chronos' ALPN support, which is only in - # the development HEAD (not yet in a tagged release). - run: nimble install -y "https://github.com/status-im/nim-chronos@#head" + run: nimble install -y chronos background: true - name: Wait setup From 3b568a47bc725afe11532df2ece97ad4d408379a Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sat, 18 Jul 2026 18:44:16 +0900 Subject: [PATCH 03/11] fix --- .github/workflows/test.yml | 6 +++--- README.md | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 439ce95..40c2108 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,9 @@ jobs: os: - 'ubuntu-latest' nim-version: - #- '2.2.4' + - '2.2.4' - 'stable' - #- 'devel' + - 'devel' name: Tests on ${{ matrix.nim-version }} (${{ matrix.os }}) steps: @@ -74,7 +74,7 @@ jobs: - name: Install chronos id: install-chronos - run: nimble install -y chronos + run: nimble install chronos -y background: true - name: Wait setup diff --git a/README.md b/README.md index b1e9f2c..aa13bbb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ Async PostgreSQL client in Nim. - Nim >= 2.2.4 +- [chronos](https://github.com/status-im/nim-chronos) >= 4.4.0 (Only chronos backend) + - [nim-bearssl](https://github.com/status-im/nim-bearssl) >= 0.2.11 (Only chronos backend; TLS 1.2 only due to BearSSL limitation) ## Installation From 58ae170a875a7aa4fad4b7611eacf77bb1ee51ab Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 01:47:37 +0900 Subject: [PATCH 04/11] fix: reject unsafe sslnegotiation=direct combos and verify ALPN on asyncdispatch --- async_postgres/pg_connection/lifecycle.nim | 17 ++++++++ async_postgres/pg_connection/ssl.nim | 51 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/async_postgres/pg_connection/lifecycle.nim b/async_postgres/pg_connection/lifecycle.nim index 2ab7165..aef3f0a 100644 --- a/async_postgres/pg_connection/lifecycle.nim +++ b/async_postgres/pg_connection/lifecycle.nim @@ -132,6 +132,15 @@ proc connectToHost*( ## Dials `entry.hostaddr` when given (bypassing name resolution), otherwise ## `entry.host`; SSL certificate verification always uses `entry.host`. + # Reject up front so the sslAllow rewrite below can't downgrade past + # negotiateSSL's own check. + if config.sslNegotiation == sslnDirect and + config.sslMode notin {sslRequire, sslVerifyCa, sslVerifyFull}: + raise newException( + PgConnectionError, + "sslnegotiation=direct requires sslmode=require, verify-ca, or verify-full", + ) + if config.sslMode == sslAllow: # sslAllow: try plaintext first, then fall back to SSL (libpq semantics). # WARNING: This is vulnerable to MITM downgrade attacks. A network @@ -171,6 +180,14 @@ proc connectToHost*( let hostPort = entry.port let isUnix = isUnixSocket(hostAddr) + # Unix sockets skip negotiateSSL; without this the caller's TLS request is + # silently dropped. + if isUnix and config.sslNegotiation == sslnDirect: + raise newException( + PgConnectionError, + "sslnegotiation=direct is not supported over Unix-domain sockets", + ) + when hasChronos: let transport = if isUnix: diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index aa0e9f9..d86431e 100644 --- a/async_postgres/pg_connection/ssl.nim +++ b/async_postgres/pg_connection/ssl.nim @@ -48,6 +48,9 @@ when hasAsyncDispatch and defined(ssl): X509SetIpAscFn = proc(param: pointer, ipasc: cstring): cint {.cdecl, gcsafe, raises: [].} SslGetBioFn = proc(ssl: SslPtr): BIO {.cdecl, gcsafe, raises: [].} + SslGet0AlpnSelectedFn = proc( + ssl: SslPtr, data: ptr pointer, len: ptr cuint + ) {.cdecl, gcsafe, raises: [].} # Apple's system libssl/libcrypto omit these symbols; an eager `{.dynlib.}` # binding would abort the process at startup. Resolve lazily and let callers @@ -63,6 +66,8 @@ when hasAsyncDispatch and defined(ssl): sslGetRbioResolved: bool sslGetWbioFn: SslGetBioFn sslGetWbioResolved: bool + sslGet0AlpnSelectedFn: SslGet0AlpnSelectedFn + sslGet0AlpnSelectedResolved: bool proc sslSet1Host*(): SslSet1HostFn = if not sslSet1HostResolved: @@ -105,6 +110,15 @@ when hasAsyncDispatch and defined(ssl): sslGetWbioResolved = true sslGetWbioFn + proc sslGet0AlpnSelected*(): SslGet0AlpnSelectedFn = + if not sslGet0AlpnSelectedResolved: + let lib = loadLibPattern(DLLSSLName) + if lib != nil: + sslGet0AlpnSelectedFn = + cast[SslGet0AlpnSelectedFn](symAddr(lib, "SSL_get0_alpn_selected")) + sslGet0AlpnSelectedResolved = true + sslGet0AlpnSelectedFn + proc formatSslError(prefix: string): string = result = prefix let code = ERR_peek_last_error() @@ -322,8 +336,15 @@ proc establishTls( if direct: # Advertise "postgresql" ALPN: 1-byte length prefix + protocol name. const alpnProto = "\x0a" & PgAlpnProtocol - discard - SSL_CTX_set_alpn_protos(ctx.context, alpnProto.cstring, cuint(alpnProto.len)) + let rc = SSL_CTX_set_alpn_protos( + ctx.context, alpnProto.cstring, cuint(alpnProto.len) + ) + if rc != 0: + raise newException( + PgConnectionError, + "failed to configure ALPN for sslnegotiation=direct " & + "(SSL_CTX_set_alpn_protos returned " & $rc & ")", + ) try: let hostname = sniName(sslHost, config.sslSni) @@ -333,6 +354,32 @@ proc establishTls( enforceVerifyFullIdentity(conn.socket.sslHandle, sslHost) # Drive handshake so peer cert is populated before SCRAM channel binding. await driveTlsHandshake(conn.socket) + if direct: + # Mirror chronos: without this a non-Postgres/pre-17 TLS peer accepts + # our handshake and we proceed with whatever it speaks. + let getAlpn = sslGet0AlpnSelected() + if getAlpn == nil: + raise newException( + PgConnectionError, + "sslnegotiation=direct: libssl does not export SSL_get0_alpn_selected", + ) + var protoPtr: pointer + var protoLen: cuint + getAlpn(conn.socket.sslHandle, addr protoPtr, addr protoLen) + if protoPtr.isNil or protoLen != cuint(PgAlpnProtocol.len): + raise newException( + PgConnectionError, + "direct SSL connection established without ALPN: the server does " & + "not support sslnegotiation=direct (requires PostgreSQL 17+)", + ) + var selected = newString(protoLen.int) + copyMem(addr selected[0], protoPtr, protoLen.int) + if selected != PgAlpnProtocol: + raise newException( + PgConnectionError, + "direct SSL connection negotiated unexpected ALPN protocol '" & + selected & "' (expected '" & PgAlpnProtocol & "')", + ) conn.sslEnabled = true # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. # If unavailable, cbPrefer will silently fall back to SCRAM-SHA-256 — From 84c092b32c6e089886a95849b65aa49f4545fbc9 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 01:54:34 +0900 Subject: [PATCH 05/11] chore: tidy direct-ssl ALPN constant, docs, and test coverage --- README.md | 3 +-- async_postgres/pg_connection/ssl.nim | 13 ++++++------ async_postgres/pg_connection/types.nim | 5 ++++- tests/test_ssl.nim | 29 +++++++++++++++++++++----- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index aa13bbb..a1070ec 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,7 @@ SSL backend differs by async backend: - chronos: BearSSL (via [nim-bearssl](https://github.com/status-im/nim-bearssl); TLS 1.2 only due to BearSSL limitation) Direct SSL negotiation (`sslnegotiation=direct`) requires `sslmode=require` or -stronger. On the chronos backend it needs chronos' ALPN support, currently only -in the development HEAD (`nimble install "https://github.com/status-im/nim-chronos@#head"`). +stronger. On the chronos backend it needs chronos >= 4.4.0 for ALPN support. ## Examples diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index d86431e..5f6e566 100644 --- a/async_postgres/pg_connection/ssl.nim +++ b/async_postgres/pg_connection/ssl.nim @@ -27,9 +27,11 @@ elif hasAsyncDispatch: when defined(ssl): import std/[dynlib, openssl, tempfiles, os] -const PgAlpnProtocol = "postgresql" - ## ALPN protocol name a Direct SSL connection must negotiate (PostgreSQL 17+). - ## libpq sends and requires the same name for `sslnegotiation=direct`. +when hasChronos or (hasAsyncDispatch and defined(ssl)): + const PgAlpnProtocol = "postgresql" + ## ALPN protocol name required for `sslnegotiation=direct` (PostgreSQL 17+). + static: + doAssert PgAlpnProtocol.len < 256 when hasAsyncDispatch and defined(ssl): # On asyncdispatch `conn.socket` is an `AsyncSocket`, so `wrapConnectedSocket` @@ -334,8 +336,7 @@ proc establishTls( ctx = newContext(verifyMode = verifyMode) if direct: - # Advertise "postgresql" ALPN: 1-byte length prefix + protocol name. - const alpnProto = "\x0a" & PgAlpnProtocol + const alpnProto = char(PgAlpnProtocol.len) & PgAlpnProtocol let rc = SSL_CTX_set_alpn_protos( ctx.context, alpnProto.cstring, cuint(alpnProto.len) ) @@ -355,8 +356,6 @@ proc establishTls( # Drive handshake so peer cert is populated before SCRAM channel binding. await driveTlsHandshake(conn.socket) if direct: - # Mirror chronos: without this a non-Postgres/pre-17 TLS peer accepts - # our handshake and we proceed with whatever it speaks. let getAlpn = sslGet0AlpnSelected() if getAlpn == nil: raise newException( diff --git a/async_postgres/pg_connection/types.nim b/async_postgres/pg_connection/types.nim index 1b0bc49..eef2c86 100644 --- a/async_postgres/pg_connection/types.nim +++ b/async_postgres/pg_connection/types.nim @@ -120,7 +120,10 @@ type ## SSL/TLS negotiation mode. `parseDsn` and `initConnConfig` default this ## to `sslPrefer` (libpq parity); a raw zero-initialized `ConnConfig` has ## `sslDisable`. - sslNegotiation*: SslNegotiation ## SSL negotiation method (default: sslnPostgres) + sslNegotiation*: SslNegotiation + ## SSL negotiation method (default: `sslnPostgres`). A raw zero-initialized + ## `ConnConfig` matches only because `sslnPostgres` is the enum's zero + ## value; reordering `SslNegotiation` would silently change that default. sslRootCert*: string ## PEM-encoded CA certificate(s) for sslVerifyCa/sslVerifyFull sslSni*: bool ## Send TLS SNI extension during the handshake (libpq `sslsni`, default diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 358ce83..9fbffe7 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -818,6 +818,20 @@ suite "Direct SSL negotiation": test "sslnegotiation=direct starts TLS immediately without an SSLRequest": var raised = false var firstByte: int = -1 + var alpnAdvertised = false + + proc containsBytes(hay: seq[byte], needle: string): bool = + if needle.len == 0 or hay.len < needle.len: + return false + for i in 0 .. hay.len - needle.len: + var m = true + for j in 0 ..< needle.len: + if hay[i + j] != byte(needle[j]): + m = false + break + if m: + return true + false proc testBody() {.async.} = let ms = startMockServer() @@ -825,11 +839,15 @@ suite "Direct SSL negotiation": proc serverHandler() {.async.} = let st = await ms.accept() try: - # Direct SSL skips the 8-byte SSLRequest (whose first byte is 0x00) and - # opens with a TLS handshake record (content type 0x16). Capture the - # opening byte, then drop the connection so the handshake fails fast. - let data = await readN(st, 1) - firstByte = int(data[0]) + # 5-byte TLS record header: type(1) + version(2) + length(2). + # "postgresql" only appears inside the ALPN extension, so finding it + # in the ClientHello body is the ALPN-wired signal. + let hdr = await readN(st, 5) + firstByte = int(hdr[0]) + let recordLen = (int(hdr[3]) shl 8) or int(hdr[4]) + if recordLen > 0: + let body = await readN(st, recordLen) + alpnAdvertised = containsBytes(body, "postgresql") except CatchableError: discard await closeClient(st) @@ -858,6 +876,7 @@ suite "Direct SSL negotiation": waitFor testBody() check firstByte == 0x16 + check alpnAdvertised check raised proc sendAuthSasl(client: MockClient, mechanisms: seq[string]): Future[void] {.async.} = From 83a7a6d6aed89c50633640e3a531c3ae8f7ab5fd Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 01:58:02 +0900 Subject: [PATCH 06/11] fix: avoid recordLen-based readN in direct-ssl ALPN test to prevent potential stall --- tests/test_ssl.nim | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 9fbffe7..039c2b8 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -839,15 +839,26 @@ suite "Direct SSL negotiation": proc serverHandler() {.async.} = let st = await ms.accept() try: - # 5-byte TLS record header: type(1) + version(2) + length(2). - # "postgresql" only appears inside the ALPN extension, so finding it - # in the ClientHello body is the ALPN-wired signal. - let hdr = await readN(st, 5) + let hdr = await readN(st, 1) firstByte = int(hdr[0]) - let recordLen = (int(hdr[3]) shl 8) or int(hdr[4]) - if recordLen > 0: - let body = await readN(st, recordLen) - alpnAdvertised = containsBytes(body, "postgresql") + except CatchableError: + discard + # Independent read after the first byte so a short/split ClientHello + # never blocks the handler waiting for a length that may not match the + # bytes actually delivered. "postgresql" only appears inside the ALPN + # extension, so its presence in whatever we grab proves ALPN was wired. + try: + when hasChronos: + var buf = newSeq[byte](4096) + let n = await st.readOnce(addr buf[0], buf.len) + buf.setLen(n) + alpnAdvertised = containsBytes(buf, "postgresql") + elif hasAsyncDispatch: + let s = await st.recv(4096) + var buf = newSeq[byte](s.len) + if s.len > 0: + copyMem(addr buf[0], addr s[0], s.len) + alpnAdvertised = containsBytes(buf, "postgresql") except CatchableError: discard await closeClient(st) From 5aa372d28ed8117a594ec81180aa3345bffeeb0a Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 01:59:55 +0900 Subject: [PATCH 07/11] fix: single readOnce in direct-ssl ALPN test --- tests/test_ssl.nim | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 039c2b8..3d65695 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -838,23 +838,21 @@ suite "Direct SSL negotiation": proc serverHandler() {.async.} = let st = await ms.accept() - try: - let hdr = await readN(st, 1) - firstByte = int(hdr[0]) - except CatchableError: - discard - # Independent read after the first byte so a short/split ClientHello - # never blocks the handler waiting for a length that may not match the - # bytes actually delivered. "postgresql" only appears inside the ALPN - # extension, so its presence in whatever we grab proves ALPN was wired. + # Single-shot read of whatever the client has already put on the wire; + # "postgresql" only lives inside the ALPN extension. Closing immediately + # afterwards mirrors the original mock's fast-abort behaviour. try: when hasChronos: var buf = newSeq[byte](4096) let n = await st.readOnce(addr buf[0], buf.len) buf.setLen(n) + if n >= 1: + firstByte = int(buf[0]) alpnAdvertised = containsBytes(buf, "postgresql") elif hasAsyncDispatch: let s = await st.recv(4096) + if s.len >= 1: + firstByte = int(byte(s[0])) var buf = newSeq[byte](s.len) if s.len > 0: copyMem(addr buf[0], addr s[0], s.len) From e3ba460eafd5418f1abf55586e1de910b727788b Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 02:06:12 +0900 Subject: [PATCH 08/11] fix: unblock mock server accept when direct-ssl weak-mode test never dials --- tests/test_ssl.nim | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 3d65695..0681d69 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -779,15 +779,18 @@ suite "Direct SSL negotiation": let ms = startMockServer() proc serverHandler() {.async.} = - let st = await ms.accept() + # The client rejects the weak sslmode locally, so the accept may never + # complete. closeServer below cancels it — treat that as "no bytes". try: - # The client must reject the weak sslmode locally and never send the - # SSLRequest or a ClientHello; the read returns 0 once it closes. - let data = await readN(st, 1) - bytesFromClient = data.len + let st = await ms.accept() + try: + let data = await readN(st, 1) + bytesFromClient = data.len + except CatchableError: + bytesFromClient = 0 + await closeClient(st) except CatchableError: bytesFromClient = 0 - await closeClient(st) let serverFut = serverHandler() @@ -807,8 +810,8 @@ suite "Direct SSL negotiation": raised = true errMentionsDirect = "sslnegotiation=direct" in e.msg - await serverFut await closeServer(ms) + await serverFut waitFor testBody() check raised From 90d961e20c0f73ad0dcb6a6ce4b8e93a6d5666b1 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 02:08:22 +0900 Subject: [PATCH 09/11] nph --- async_postgres/pg_connection/ssl.nim | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index 5f6e566..23b07d0 100644 --- a/async_postgres/pg_connection/ssl.nim +++ b/async_postgres/pg_connection/ssl.nim @@ -50,9 +50,8 @@ when hasAsyncDispatch and defined(ssl): X509SetIpAscFn = proc(param: pointer, ipasc: cstring): cint {.cdecl, gcsafe, raises: [].} SslGetBioFn = proc(ssl: SslPtr): BIO {.cdecl, gcsafe, raises: [].} - SslGet0AlpnSelectedFn = proc( - ssl: SslPtr, data: ptr pointer, len: ptr cuint - ) {.cdecl, gcsafe, raises: [].} + SslGet0AlpnSelectedFn = + proc(ssl: SslPtr, data: ptr pointer, len: ptr cuint) {.cdecl, gcsafe, raises: [].} # Apple's system libssl/libcrypto omit these symbols; an eager `{.dynlib.}` # binding would abort the process at startup. Resolve lazily and let callers @@ -337,9 +336,8 @@ proc establishTls( if direct: const alpnProto = char(PgAlpnProtocol.len) & PgAlpnProtocol - let rc = SSL_CTX_set_alpn_protos( - ctx.context, alpnProto.cstring, cuint(alpnProto.len) - ) + let rc = + SSL_CTX_set_alpn_protos(ctx.context, alpnProto.cstring, cuint(alpnProto.len)) if rc != 0: raise newException( PgConnectionError, @@ -376,8 +374,8 @@ proc establishTls( if selected != PgAlpnProtocol: raise newException( PgConnectionError, - "direct SSL connection negotiated unexpected ALPN protocol '" & - selected & "' (expected '" & PgAlpnProtocol & "')", + "direct SSL connection negotiated unexpected ALPN protocol '" & selected & + "' (expected '" & PgAlpnProtocol & "')", ) conn.sslEnabled = true # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. From a027f7b1fe7d3ec155b095b13c207ab5106f7ca9 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Sun, 19 Jul 2026 03:06:13 +0900 Subject: [PATCH 10/11] Some fixes --- async_postgres/pg_connection/dsn.nim | 2 +- async_postgres/pg_connection/lifecycle.nim | 28 +- async_postgres/pg_connection/ssl.nim | 294 ++++++++++----------- async_postgres/pg_connection/types.nim | 8 +- tests/test_ssl.nim | 142 +++++++--- 5 files changed, 255 insertions(+), 219 deletions(-) diff --git a/async_postgres/pg_connection/dsn.nim b/async_postgres/pg_connection/dsn.nim index 3e6561e..628c7df 100644 --- a/async_postgres/pg_connection/dsn.nim +++ b/async_postgres/pg_connection/dsn.nim @@ -487,7 +487,6 @@ proc initConnConfig*( password = "", database = "", sslMode = sslPrefer, - sslNegotiation = sslnPostgres, sslRootCert = "", sslSni = true, channelBinding = cbPrefer, @@ -504,6 +503,7 @@ proc initConnConfig*( extraParams: seq[(string, string)] = @[], maxMessageSize = 0, maxScramIterations = 0, + sslNegotiation = sslnPostgres, ): ConnConfig = ## Create a connection configuration with sensible defaults. ## For DSN-based configuration, use `parseDsn` instead. diff --git a/async_postgres/pg_connection/lifecycle.nim b/async_postgres/pg_connection/lifecycle.nim index aef3f0a..8e212bf 100644 --- a/async_postgres/pg_connection/lifecycle.nim +++ b/async_postgres/pg_connection/lifecycle.nim @@ -132,14 +132,11 @@ proc connectToHost*( ## Dials `entry.hostaddr` when given (bypassing name resolution), otherwise ## `entry.host`; SSL certificate verification always uses `entry.host`. - # Reject up front so the sslAllow rewrite below can't downgrade past - # negotiateSSL's own check. - if config.sslNegotiation == sslnDirect and - config.sslMode notin {sslRequire, sslVerifyCa, sslVerifyFull}: - raise newException( - PgConnectionError, - "sslnegotiation=direct requires sslmode=require, verify-ca, or verify-full", - ) + # Preflight up front so the sslAllow rewrite below (which mutates sslMode to + # sslDisable) cannot mask the sslnDirect intent. `connect` also validates + # before the failover loop so this is a no-op there; direct callers of + # `connectToHost` still benefit. + validateDirectSslCompatible(config) if config.sslMode == sslAllow: # sslAllow: try plaintext first, then fall back to SSL (libpq semantics). @@ -174,19 +171,14 @@ proc connectToHost*( e, ) - var conn: PgConnection - let hostAddr = entry.dialAddr let hostPort = entry.port let isUnix = isUnixSocket(hostAddr) + # sslnegotiation is ignored for Unix-domain sockets (libpq 17: AF_UNIX forces + # plaintext regardless). The `not isUnix` guard on `negotiateSSL` below + # implements that; no early hard-fail here. - # Unix sockets skip negotiateSSL; without this the caller's TLS request is - # silently dropped. - if isUnix and config.sslNegotiation == sslnDirect: - raise newException( - PgConnectionError, - "sslnegotiation=direct is not supported over Unix-domain sockets", - ) + var conn: PgConnection when hasChronos: let transport = @@ -601,6 +593,8 @@ proc connect*(config: ConnConfig): Future[PgConnection] = # `hosts` is already ordered by the caller (shuffled under lbhRandom), so # both the preferStandby two-pass loop and the single-pass loop below share # one order. + # Host-independent config errors are surfaced once, not aggregated per host. + validateDirectSslCompatible(config) var errors: seq[string] # With a single host there is no failover. Preserve the contract that its # `connectTimeout` surfaces as a raw `AsyncTimeoutError` (callers and the diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index 23b07d0..bdf44fb 100644 --- a/async_postgres/pg_connection/ssl.nim +++ b/async_postgres/pg_connection/ssl.nim @@ -15,7 +15,7 @@ ## ## Re-exported through `pg_connection.nim`. -import std/net +import std/[net, strutils] import ../[async_backend, pg_errors, pg_protocol] import types, buffer_io @@ -30,8 +30,10 @@ elif hasAsyncDispatch: when hasChronos or (hasAsyncDispatch and defined(ssl)): const PgAlpnProtocol = "postgresql" ## ALPN protocol name required for `sslnegotiation=direct` (PostgreSQL 17+). - static: - doAssert PgAlpnProtocol.len < 256 + +when hasAsyncDispatch and defined(ssl): + const PgAlpnWire = char(PgAlpnProtocol.len) & PgAlpnProtocol + ## Length-prefixed wire form for `SSL_CTX_set_alpn_protos` (RFC 7301 §3.1). when hasAsyncDispatch and defined(ssl): # On asyncdispatch `conn.socket` is an `AsyncSocket`, so `wrapConnectedSocket` @@ -52,73 +54,41 @@ when hasAsyncDispatch and defined(ssl): SslGetBioFn = proc(ssl: SslPtr): BIO {.cdecl, gcsafe, raises: [].} SslGet0AlpnSelectedFn = proc(ssl: SslPtr, data: ptr pointer, len: ptr cuint) {.cdecl, gcsafe, raises: [].} + SslCtxSetAlpnProtosFn = proc(ctx: SslCtx, protos: cstring, protos_len: cuint): cint {. + cdecl, gcsafe, raises: [] + .} - # Apple's system libssl/libcrypto omit these symbols; an eager `{.dynlib.}` - # binding would abort the process at startup. Resolve lazily and let callers - # handle nil. - var - sslSet1HostFn: SslSet1HostFn - sslSet1HostResolved: bool - sslGet0ParamFn: SslGet0ParamFn - sslGet0ParamResolved: bool - x509SetIpAscFn: X509SetIpAscFn - x509SetIpAscResolved: bool - sslGetRbioFn: SslGetBioFn - sslGetRbioResolved: bool - sslGetWbioFn: SslGetBioFn - sslGetWbioResolved: bool - sslGet0AlpnSelectedFn: SslGet0AlpnSelectedFn - sslGet0AlpnSelectedResolved: bool - - proc sslSet1Host*(): SslSet1HostFn = - if not sslSet1HostResolved: - let lib = loadLibPattern(DLLSSLName) - if lib != nil: - sslSet1HostFn = cast[SslSet1HostFn](symAddr(lib, "SSL_set1_host")) - sslSet1HostResolved = true - sslSet1HostFn - - proc sslGet0Param*(): SslGet0ParamFn = - if not sslGet0ParamResolved: - let lib = loadLibPattern(DLLSSLName) - if lib != nil: - sslGet0ParamFn = cast[SslGet0ParamFn](symAddr(lib, "SSL_get0_param")) - sslGet0ParamResolved = true - sslGet0ParamFn - - proc x509VerifyParamSet1IpAsc*(): X509SetIpAscFn = - if not x509SetIpAscResolved: - let lib = loadLibPattern(DLLUtilName) - if lib != nil: - x509SetIpAscFn = - cast[X509SetIpAscFn](symAddr(lib, "X509_VERIFY_PARAM_set1_ip_asc")) - x509SetIpAscResolved = true - x509SetIpAscFn - - proc sslGetRbio*(): SslGetBioFn = - if not sslGetRbioResolved: - let lib = loadLibPattern(DLLSSLName) - if lib != nil: - sslGetRbioFn = cast[SslGetBioFn](symAddr(lib, "SSL_get_rbio")) - sslGetRbioResolved = true - sslGetRbioFn - - proc sslGetWbio*(): SslGetBioFn = - if not sslGetWbioResolved: - let lib = loadLibPattern(DLLSSLName) - if lib != nil: - sslGetWbioFn = cast[SslGetBioFn](symAddr(lib, "SSL_get_wbio")) - sslGetWbioResolved = true - sslGetWbioFn + # Apple's system libssl/libcrypto omit some of these symbols; an eager + # `{.dynlib.}` binding would abort the process at startup. Resolve lazily and + # let callers handle nil. + template defineLazySym( + procName: untyped, FnType: typedesc, libPattern: string, symbol: string + ) = + var + cachedFn {.global.}: FnType + resolved {.global.}: bool + proc procName*(): FnType = + if not resolved: + let lib = loadLibPattern(libPattern) + if lib != nil: + cachedFn = cast[FnType](symAddr(lib, symbol)) + resolved = true + cachedFn - proc sslGet0AlpnSelected*(): SslGet0AlpnSelectedFn = - if not sslGet0AlpnSelectedResolved: - let lib = loadLibPattern(DLLSSLName) - if lib != nil: - sslGet0AlpnSelectedFn = - cast[SslGet0AlpnSelectedFn](symAddr(lib, "SSL_get0_alpn_selected")) - sslGet0AlpnSelectedResolved = true - sslGet0AlpnSelectedFn + defineLazySym(sslSet1Host, SslSet1HostFn, DLLSSLName, "SSL_set1_host") + defineLazySym(sslGet0Param, SslGet0ParamFn, DLLSSLName, "SSL_get0_param") + defineLazySym( + x509VerifyParamSet1IpAsc, X509SetIpAscFn, DLLUtilName, + "X509_VERIFY_PARAM_set1_ip_asc", + ) + defineLazySym(sslGetRbio, SslGetBioFn, DLLSSLName, "SSL_get_rbio") + defineLazySym(sslGetWbio, SslGetBioFn, DLLSSLName, "SSL_get_wbio") + defineLazySym( + sslGet0AlpnSelected, SslGet0AlpnSelectedFn, DLLSSLName, "SSL_get0_alpn_selected" + ) + defineLazySym( + sslCtxSetAlpnProtos, SslCtxSetAlpnProtosFn, DLLSSLName, "SSL_CTX_set_alpn_protos" + ) proc formatSslError(prefix: string): string = result = prefix @@ -190,6 +160,22 @@ when hasAsyncDispatch and defined(ssl): formatSslError("TLS handshake failed (SSL_get_error=" & $err & ")"), ) + proc getSelectedAlpnOpenssl(ssl: SslPtr): string = + ## Return the peer-selected ALPN protocol, or "" if none was selected. + let getAlpn = sslGet0AlpnSelected() + if getAlpn == nil: + raise newException( + PgConnectionError, + "sslnegotiation=direct: libssl does not export SSL_get0_alpn_selected", + ) + var protoPtr: pointer + var protoLen: cuint + getAlpn(ssl, addr protoPtr, addr protoLen) + if protoPtr.isNil or protoLen == 0: + return "" + result = newString(protoLen.int) + copyMem(addr result[0], protoPtr, protoLen.int) + proc enforceVerifyFullIdentity(sslHandle: SslPtr, host: string) = ## Make OpenSSL match the peer certificate against `host` during the deferred ## handshake (iPAddress SANs for an IP literal, DNS name otherwise), so @@ -228,6 +214,34 @@ when hasAsyncDispatch and defined(ssl): host, ) +proc validateDirectSslCompatible*(config: ConnConfig) {.raises: [PgConnectionError].} = + ## Reject `sslnegotiation=direct` under a weak `sslmode`. Direct SSL skips + ## the SSLRequest probe so it has no plaintext fall-back path (libpq parity). + ## Idempotent — safe to call from any layer. + if config.sslNegotiation == sslnDirect and + config.sslMode notin {sslRequire, sslVerifyCa, sslVerifyFull}: + raise newException( + PgConnectionError, + "sslnegotiation=direct requires sslmode=require, verify-ca, or verify-full", + ) + +when hasChronos or (hasAsyncDispatch and defined(ssl)): + proc assertAlpnPostgres(selected: string) {.raises: [PgConnectionError].} = + if selected.len == 0: + raise newException( + PgConnectionError, + "direct SSL connection established without ALPN: the server does not " & + "support sslnegotiation=direct (requires PostgreSQL 17+)", + ) + if selected != PgAlpnProtocol: + # Peer-controlled value: escape non-printable bytes so an embedded NUL + # can't truncate a C-string logger and hide the actual selection. + raise newException( + PgConnectionError, + "direct SSL connection negotiated unexpected ALPN protocol '" & + selected.escape("", "") & "' (expected '" & PgAlpnProtocol & "')", + ) + proc sniName*(sslHost: string, sslSni: bool): string = ## Value for the TLS SNI extension. Empty means "do not send SNI". ## Matches libpq: SNI is on by default and suppressed for IP literals @@ -241,14 +255,14 @@ proc sniName*(sslHost: string, sslSni: bool): string = return "" sslHost -proc establishTls( - conn: PgConnection, config: ConnConfig, sslHost: string, direct: bool -) {.async.} = - ## Run the TLS handshake over `conn`'s transport and wire up the encrypted - ## reader/writer. Shared by traditional (post-`SSLRequest`) and Direct SSL - ## negotiation. When `direct` is true the client offers the "postgresql" ALPN - ## protocol and requires the server to select it, matching libpq's - ## `sslnegotiation=direct` behaviour (PostgreSQL 17+). +proc establishTls(conn: PgConnection, sslHost: string, direct: bool) {.async.} = + ## Run the TLS handshake and wire up the encrypted reader/writer. `direct=true` + ## enforces the "postgresql" ALPN selection (libpq `sslnegotiation=direct`, + ## PostgreSQL 17+). Reads TLS parameters from `conn.config`. + # Alias, not a copy: an async closure would deep-copy a `let config = ...`. + template config(): ConnConfig = + conn.config + when hasChronos: conn.baseReader = newAsyncStreamReader(conn.transport) conn.baseWriter = newAsyncStreamWriter(conn.transport) @@ -262,18 +276,16 @@ proc establishTls( else: {TLSFlags.NoVerifyHost, TLSFlags.NoVerifyServerName} - # verify-full: sslHost drives BearSSL's X509 name check; else honor sslSni. + # BearSSL's serverName doubles as SNI wire value and X509 name check input. + # Under verify-full it must be sslHost for BearSSL to verify; other modes + # honor sslSni and RFC 6066 IP-literal suppression. let serverName = if config.sslMode == sslVerifyFull: sslHost else: sniName(sslHost, config.sslSni) - let alpn = - if direct: - @[PgAlpnProtocol] - else: - @[] - + # Advertise ALPN on every TLS connection (libpq 17 parity: SSL_set_alpn_protos + # is called unconditionally); enforcement stays direct-only below. if config.sslMode in {sslVerifyCa, sslVerifyFull}: let parsed = parseTrustAnchors(config.sslRootCert) conn.trustAnchorBufs = parsed.backing @@ -286,7 +298,7 @@ proc establishTls( minVersion = TLSVersion.TLS12, maxVersion = TLSVersion.TLS12, trustAnchors = parsed.store, - alpnProtocols = alpn, + alpnProtocols = [PgAlpnProtocol], ) else: # NoVerifyHost is set, so trust anchors are ignored regardless. @@ -297,91 +309,62 @@ proc establishTls( flags = flags, minVersion = TLSVersion.TLS12, maxVersion = TLSVersion.TLS12, - alpnProtocols = alpn, + alpnProtocols = [PgAlpnProtocol], ) installX509Capture( conn.x509Capture, conn.tlsStream.ccontext.eng, addr conn.serverCertDer ) await conn.tlsStream.handshake() - if direct and conn.tlsStream.getSelectedAlpnProtocol() != PgAlpnProtocol: - raise newException( - PgConnectionError, - "direct SSL connection established without ALPN: the server does not " & - "support sslnegotiation=direct (requires PostgreSQL 17+)", - ) + if direct: + assertAlpnPostgres(conn.tlsStream.getSelectedAlpnProtocol()) conn.reader = conn.tlsStream.reader conn.writer = conn.tlsStream.writer conn.sslEnabled = true elif hasAsyncDispatch: when defined(ssl): - let verifyMode = - case config.sslMode - of sslVerifyCa, sslVerifyFull: SslCVerifyMode.CVerifyPeer - else: SslCVerifyMode.CVerifyNone - var ctx: SslContext var tmpPath: string - if config.sslMode in {sslVerifyCa, sslVerifyFull}: - let (tmpFile, tp) = createTempFile("pg_ca_", ".pem") - tmpPath = tp - try: + try: + if config.sslMode in {sslVerifyCa, sslVerifyFull}: + let (tmpFile, tp) = createTempFile("pg_ca_", ".pem") + tmpPath = tp tmpFile.write(config.sslRootCert) tmpFile.close() - ctx = newContext(verifyMode = verifyMode, caFile = tmpPath) - except: - removeFile(tmpPath) - raise - else: - ctx = newContext(verifyMode = verifyMode) + ctx = newContext(verifyMode = CVerifyPeer, caFile = tmpPath) + else: + ctx = newContext(verifyMode = CVerifyNone) - if direct: - const alpnProto = char(PgAlpnProtocol.len) & PgAlpnProtocol - let rc = - SSL_CTX_set_alpn_protos(ctx.context, alpnProto.cstring, cuint(alpnProto.len)) - if rc != 0: + # Advertise ALPN unconditionally (libpq 17 parity). The missing-symbol + # path is only fatal for direct mode; traditional mode degrades to no-ALPN. + let setAlpn = sslCtxSetAlpnProtos() + if setAlpn != nil: + let rc = setAlpn(ctx.context, PgAlpnWire.cstring, cuint(PgAlpnWire.len)) + if rc != 0: + raise newException( + PgConnectionError, + "failed to configure ALPN (SSL_CTX_set_alpn_protos returned " & $rc & ")", + ) + elif direct: raise newException( PgConnectionError, - "failed to configure ALPN for sslnegotiation=direct " & - "(SSL_CTX_set_alpn_protos returned " & $rc & ")", + "sslnegotiation=direct: libssl does not export SSL_CTX_set_alpn_protos", ) - try: let hostname = sniName(sslHost, config.sslSni) wrapConnectedSocket(ctx, conn.socket, handshakeAsClient, hostname) # asyncnet skips name matching; make OpenSSL enforce it during handshake. if config.sslMode == sslVerifyFull: enforceVerifyFullIdentity(conn.socket.sslHandle, sslHost) - # Drive handshake so peer cert is populated before SCRAM channel binding. + # Drive the handshake now so the peer cert is available before SCRAM + # decides channel binding (asyncnet defers it to the first send/recv). await driveTlsHandshake(conn.socket) if direct: - let getAlpn = sslGet0AlpnSelected() - if getAlpn == nil: - raise newException( - PgConnectionError, - "sslnegotiation=direct: libssl does not export SSL_get0_alpn_selected", - ) - var protoPtr: pointer - var protoLen: cuint - getAlpn(conn.socket.sslHandle, addr protoPtr, addr protoLen) - if protoPtr.isNil or protoLen != cuint(PgAlpnProtocol.len): - raise newException( - PgConnectionError, - "direct SSL connection established without ALPN: the server does " & - "not support sslnegotiation=direct (requires PostgreSQL 17+)", - ) - var selected = newString(protoLen.int) - copyMem(addr selected[0], protoPtr, protoLen.int) - if selected != PgAlpnProtocol: - raise newException( - PgConnectionError, - "direct SSL connection negotiated unexpected ALPN protocol '" & selected & - "' (expected '" & PgAlpnProtocol & "')", - ) + assertAlpnPostgres(getSelectedAlpnOpenssl(conn.socket.sslHandle)) conn.sslEnabled = true # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. - # If unavailable, cbPrefer will silently fall back to SCRAM-SHA-256 — - # warn the operator so the loss of channel binding is observable. - # (cbRequire is enforced in selectScramMechanism.) + # If unavailable, cbPrefer silently falls back to SCRAM-SHA-256 — warn + # so the loss of channel binding is observable. (cbRequire is enforced + # in selectScramMechanism.) let peerCert = SSL_get_peer_certificate(conn.socket.sslHandle) if peerCert != nil: try: @@ -397,6 +380,12 @@ proc establishTls( else: stderr.writeLine "pg_connection: server certificate unavailable; SCRAM-SHA-256-PLUS channel binding unavailable" finally: + # asyncnet doesn't free the SslContext (no =destroy on std/net's type). + # SSL_new inside wrapConnectedSocket takes its own ref, so destroying + # here on both success and failure balances newContext without freeing + # the SSL's copy — the socket's SSL_free drops that one on close. + if ctx != nil: + ctx.destroyContext() if tmpPath.len > 0: removeFile(tmpPath) else: @@ -404,12 +393,11 @@ proc establishTls( newException(PgConnectionError, "SSL support requires compiling with -d:ssl") proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.async.} = - ## Negotiate TLS for the connection. With `sslnegotiation=postgres` (default) - ## this sends an `SSLRequest` and starts TLS only if the server accepts; - ## with `sslnegotiation=direct` it starts TLS immediately without the - ## round-trip (PostgreSQL 17+). `sslHost` is the host *name* the server - ## certificate is verified against (the entry's `host`, never its `hostaddr` — - ## libpq semantics). + ## Negotiate TLS. `sslnegotiation=postgres` (default) sends an SSLRequest first; + ## `sslnegotiation=direct` starts TLS immediately (PostgreSQL 17+). `sslHost` + ## is the name matched against the server certificate (libpq semantics: the + ## entry's `host`, never `hostaddr`). + validateDirectSslCompatible(config) if config.sslMode in {sslVerifyCa, sslVerifyFull} and config.sslRootCert.len == 0: # Both backends silently fall back to a Web PKI store (chronos: # MozillaTrustAnchors, std/net: OS CA bundle) — for verify-ca that also @@ -425,15 +413,7 @@ proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.as ) if config.sslNegotiation == sslnDirect: - # Direct SSL skips the SSLRequest probe, so there is no plaintext path to - # fall back to. libpq rejects weak sslmodes here for the same reason; SSL - # must actually be required. - if config.sslMode notin {sslRequire, sslVerifyCa, sslVerifyFull}: - raise newException( - PgConnectionError, - "sslnegotiation=direct requires sslmode=require, verify-ca, or verify-full", - ) - await establishTls(conn, config, sslHost, direct = true) + await establishTls(conn, sslHost, direct = true) return let sslReq = encodeSSLRequest() @@ -481,7 +461,7 @@ proc negotiateSSL*(conn: PgConnection, config: ConnConfig, sslHost: string) {.as PgConnectionError, "Received unencrypted data after SSL response (possible man-in-the-middle)", ) - await establishTls(conn, config, sslHost, direct = false) + await establishTls(conn, sslHost, direct = false) of 'N': if config.sslMode in {sslRequire, sslVerifyCa, sslVerifyFull}: raise newException(PgConnectionError, "Server does not support SSL") diff --git a/async_postgres/pg_connection/types.nim b/async_postgres/pg_connection/types.nim index eef2c86..2c2ea76 100644 --- a/async_postgres/pg_connection/types.nim +++ b/async_postgres/pg_connection/types.nim @@ -122,8 +122,8 @@ type ## `sslDisable`. sslNegotiation*: SslNegotiation ## SSL negotiation method (default: `sslnPostgres`). A raw zero-initialized - ## `ConnConfig` matches only because `sslnPostgres` is the enum's zero - ## value; reordering `SslNegotiation` would silently change that default. + ## `ConnConfig` matches because `sslnPostgres` is the enum's zero value, + ## which is locked in by a `static:` assertion on `SslNegotiation`. sslRootCert*: string ## PEM-encoded CA certificate(s) for sslVerifyCa/sslVerifyFull sslSni*: bool ## Send TLS SNI extension during the handshake (libpq `sslsni`, default @@ -675,6 +675,10 @@ type ## nil). Advisory only — the macro's behaviour is unchanged. Use this ## to observe unlock failures that would otherwise be invisible. +static: + # Zero-initialized ConnConfig must default to sslnPostgres. + doAssert ord(sslnPostgres) == 0 + type RowCallback* = proc(row: Row) {.raises: [CatchableError], gcsafe.} ## Callback invoked once per row during `queryEach`. The `Row` is only valid ## inside the callback — its backing buffer is reused for the next row. diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 0681d69..53abd55 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -1,6 +1,6 @@ import std/[unittest, strutils, os] -import ../async_postgres/[async_backend, pg_protocol] +import ../async_postgres/[async_backend, pg_bytes, pg_protocol] import ../async_postgres/pg_connection {.all.} @@ -773,24 +773,26 @@ suite "Direct SSL negotiation": test "sslnegotiation=direct rejects weak sslmode before any bytes are sent": var raised = false var errMentionsDirect = false - var bytesFromClient = -1 + var bytesFromClient = 0 proc testBody() {.async.} = let ms = startMockServer() proc serverHandler() {.async.} = - # The client rejects the weak sslmode locally, so the accept may never - # complete. closeServer below cancels it — treat that as "no bytes". + var st: MockClient + try: + st = await ms.accept() + except CatchableError: + return + try: + let data = await readN(st, 1) + bytesFromClient = data.len + except CatchableError: + discard try: - let st = await ms.accept() - try: - let data = await readN(st, 1) - bytesFromClient = data.len - except CatchableError: - bytesFromClient = 0 await closeClient(st) except CatchableError: - bytesFromClient = 0 + discard let serverFut = serverHandler() @@ -823,43 +825,23 @@ suite "Direct SSL negotiation": var firstByte: int = -1 var alpnAdvertised = false - proc containsBytes(hay: seq[byte], needle: string): bool = - if needle.len == 0 or hay.len < needle.len: - return false - for i in 0 .. hay.len - needle.len: - var m = true - for j in 0 ..< needle.len: - if hay[i + j] != byte(needle[j]): - m = false - break - if m: - return true - false - proc testBody() {.async.} = let ms = startMockServer() proc serverHandler() {.async.} = let st = await ms.accept() - # Single-shot read of whatever the client has already put on the wire; - # "postgresql" only lives inside the ALPN extension. Closing immediately - # afterwards mirrors the original mock's fast-abort behaviour. + # 5-byte TLS record header: type(1) + version(2) + length(2). Reading + # the full record avoids racing packet boundaries under load/MTU splits. try: - when hasChronos: - var buf = newSeq[byte](4096) - let n = await st.readOnce(addr buf[0], buf.len) - buf.setLen(n) - if n >= 1: - firstByte = int(buf[0]) - alpnAdvertised = containsBytes(buf, "postgresql") - elif hasAsyncDispatch: - let s = await st.recv(4096) - if s.len >= 1: - firstByte = int(byte(s[0])) - var buf = newSeq[byte](s.len) - if s.len > 0: - copyMem(addr buf[0], addr s[0], s.len) - alpnAdvertised = containsBytes(buf, "postgresql") + let header = await readN(st, 5) + firstByte = int(header[0]) + let recordLen = (int(header[3]) shl 8) or int(header[4]) + if recordLen > 0: + let body = await readN(st, recordLen) + # "postgresql" cannot straddle into the fixed 5-byte record header, + # so searching the body alone is equivalent to searching the whole + # record (the ALPN extension always lives inside the ClientHello). + alpnAdvertised = "postgresql" in readString(body, 0, body.len) except CatchableError: discard await closeClient(st) @@ -891,6 +873,82 @@ suite "Direct SSL negotiation": check alpnAdvertised check raised + test "sslnegotiation=postgres advertises 'postgresql' ALPN in ClientHello (libpq 17 parity)": + var raised = false + var alpnAdvertised = false + + proc testBody() {.async.} = + let ms = startMockServer() + + proc serverHandler() {.async.} = + let st = await ms.accept() + try: + discard await readN(st, 8) # SSLRequest + await sendBytes(st, @[byte('S')]) + # TLS record: type(1) + version(2) + length(2) + let header = await readN(st, 5) + let recordLen = (int(header[3]) shl 8) or int(header[4]) + if recordLen > 0: + let body = await readN(st, recordLen) + alpnAdvertised = "postgresql" in readString(body, 0, body.len) + except CatchableError: + discard + await closeClient(st) + + let serverFut = serverHandler() + + let config = ConnConfig( + host: "127.0.0.1", + port: ms.port, + user: "test", + database: "test", + sslMode: sslRequire, + sslNegotiation: sslnPostgres, + ) + + try: + let conn = await connect(config) + await conn.close() + except PgError: + # Mock cannot complete TLS handshake; connect fails after ClientHello. + raised = true + + await serverFut + await closeServer(ms) + + waitFor testBody() + check alpnAdvertised + check raised + + test "sslnegotiation=direct with weak sslmode fails once across a multi-host list": + var raised = false + var errMsg = "" + + proc testBody() {.async.} = + # Three hosts on port 1 (guaranteed refused). A per-host validate would + # append the identical config error three times; the pre-loop validate + # surfaces it once as a raw PgConnectionError, not the "Could not connect + # to any host: h1: ...; h2: ...; h3: ..." aggregate. + let config = ConnConfig( + host: "127.0.0.1,127.0.0.1,127.0.0.1", + port: 1, + user: "test", + database: "test", + sslMode: sslPrefer, + sslNegotiation: sslnDirect, + ) + try: + let conn = await connect(config) + await conn.close() + except PgError as e: + raised = true + errMsg = e.msg + + waitFor testBody() + check raised + check "sslnegotiation=direct requires" in errMsg + check "Could not connect to any host" notin errMsg + proc sendAuthSasl(client: MockClient, mechanisms: seq[string]): Future[void] {.async.} = var body: seq[byte] = @[] body.addInt32(10) # AuthenticationSASL From 5df44b258fa90a1b030b359c900e766146fe0e86 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Mon, 20 Jul 2026 17:22:56 +0900 Subject: [PATCH 11/11] fix --- tests/test_ssl.nim | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_ssl.nim b/tests/test_ssl.nim index 53abd55..fa16842 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -922,7 +922,8 @@ suite "Direct SSL negotiation": test "sslnegotiation=direct with weak sslmode fails once across a multi-host list": var raised = false - var errMsg = "" + var errMentionsDirect = false + var noAggregateMsg = false proc testBody() {.async.} = # Three hosts on port 1 (guaranteed refused). A per-host validate would @@ -942,12 +943,13 @@ suite "Direct SSL negotiation": await conn.close() except PgError as e: raised = true - errMsg = e.msg + errMentionsDirect = "sslnegotiation=direct requires" in e.msg + noAggregateMsg = "Could not connect to any host" notin e.msg waitFor testBody() check raised - check "sslnegotiation=direct requires" in errMsg - check "Could not connect to any host" notin errMsg + check errMentionsDirect + check noAggregateMsg proc sendAuthSasl(client: MockClient, mechanisms: seq[string]): Future[void] {.async.} = var body: seq[byte] = @[]