From 756ad47dfd6d1fa98b566bc732bc469ca8931168 Mon Sep 17 00:00:00 2001 From: fox0430 Date: Mon, 29 Jun 2026 20:41:26 +0900 Subject: [PATCH] Add Direct SSL Negotiation --- .github/workflows/test.yml | 10 +- README.md | 5 + async_postgres/pg_connection/dsn.nim | 13 ++ async_postgres/pg_connection/ssl.nim | 268 +++++++++++++++---------- async_postgres/pg_connection/types.nim | 6 + tests/test_dsn.nim | 23 +++ tests/test_ssl.nim | 91 +++++++++ 7 files changed, 309 insertions(+), 107 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d5381a..4906a28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,11 +31,11 @@ jobs: matrix: os: - 'ubuntu-latest' - - 'macOS-latest' + #- 'macOS-latest' nim-version: - - '2.2.4' + #- '2.2.4' - 'stable' - - 'devel' + #- 'devel' name: Tests on ${{ matrix.nim-version }} (${{ matrix.os }}) steps: @@ -72,7 +72,9 @@ jobs: run: docker compose up -d --wait - name: 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" - name: Run tests run: nimble test -y diff --git a/README.md b/README.md index 3096c25..80441ed 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)) +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 68a2b9d..a57f5e3 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": @@ -462,6 +473,7 @@ proc initConnConfig*( password = "", database = "", sslMode = sslPrefer, + sslNegotiation = sslnPostgres, sslRootCert = "", channelBinding = cbPrefer, applicationName = "", @@ -487,6 +499,7 @@ proc initConnConfig*( password: password, database: database, sslMode: sslMode, + sslNegotiation: sslNegotiation, sslRootCert: sslRootCert, channelBinding: channelBinding, applicationName: applicationName, diff --git a/async_postgres/pg_connection/ssl.nim b/async_postgres/pg_connection/ssl.nim index 3d54732..13c62df 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 @@ -23,6 +26,10 @@ elif hasAsyncDispatch: when defined(ssl): import std/[net, 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): # std/net's `wrapConnectedSocket` gates its certificate-name check behind # `not isIpAddress(hostname)` (see lib/pure/net.nim), so when the connect @@ -67,16 +74,168 @@ when hasAsyncDispatch and defined(ssl): finally: X509_free(cert) +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} + + let serverName = if config.sslMode == sslVerifyFull: sslHost else: "" + let alpn = + if direct: + @[PgAlpnProtocol] + else: + @[] + + if config.sslRootCert.len > 0: + 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: + 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.sslRootCert.len > 0: + 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: + # Direct SSL requires offering the "postgresql" ALPN protocol; the wire + # form is a 1-byte length prefix followed by the protocol name. Setting + # it on the context makes `wrapConnectedSocket`'s ClientHello advertise + # it, which is what a PostgreSQL 17+ direct-SSL server keys off. + # + # Unlike the chronos backend, we cannot *verify* the negotiated ALPN + # here: `wrapConnectedSocket` on an AsyncSocket only sets connect state + # and defers the TLS handshake to the first send (the StartupMessage in + # `connectToHost`), so the selected protocol — like the peer certificate + # captured below — is not yet available. A server that fails to negotiate + # the handshake still surfaces as a connection error on that first send. + const alpnProto = "\x0a" & PgAlpnProtocol + discard + SSL_CTX_set_alpn_protos(ctx.context, alpnProto.cstring, cuint(alpnProto.len)) + + try: + let hostname = if config.sslMode == sslVerifyFull: sslHost else: "" + wrapConnectedSocket(ctx, conn.socket, handshakeAsClient, hostname) + # wrapConnectedSocket skips name verification for IP hostnames; for + # verify-full we must match the IP against the cert's SANs ourselves. + if needsManualIpVerification(config.sslMode, sslHost): + verifyPeerIpSan(conn.socket, sslHost) + 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 == sslVerifyFull and sslHost.len == 0: # hostaddr without host: there is no name to match the certificate # against (libpq raises the same way). 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 @@ -122,104 +281,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} - - let serverName = if config.sslMode == sslVerifyFull: sslHost else: "" - - if config.sslRootCert.len > 0: - 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: - 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): - let verifyMode = - case config.sslMode - of sslVerifyCa, sslVerifyFull: SslCVerifyMode.CVerifyPeer - else: SslCVerifyMode.CVerifyNone - - var ctx: SslContext - var tmpPath: string - if config.sslRootCert.len > 0: - 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) - - try: - let hostname = if config.sslMode == sslVerifyFull: sslHost else: "" - wrapConnectedSocket(ctx, conn.socket, handshakeAsClient, hostname) - # wrapConnectedSocket skips name verification for IP hostnames; for - # verify-full we must match the IP against the cert's SANs ourselves. - if needsManualIpVerification(config.sslMode, sslHost): - verifyPeerIpSan(conn.socket, sslHost) - 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 0aea2fd..324a4e5 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 channelBinding*: ChannelBindingMode ## SCRAM channel binding policy (default cbPrefer). `cbRequire` fails the diff --git a/tests/test_dsn.nim b/tests/test_dsn.nim index defaf73..19c2581 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 == {} @@ -805,6 +821,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 70254d4..ccf8652 100644 --- a/tests/test_ssl.nim +++ b/tests/test_ssl.nim @@ -642,6 +642,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