diff --git a/README.md b/README.md index f6ed321..a1070ec 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 @@ -67,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 @@ -129,6 +132,9 @@ 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 >= 4.4.0 for ALPN support. + ## 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..628c7df 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": @@ -492,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. @@ -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/lifecycle.nim b/async_postgres/pg_connection/lifecycle.nim index 2ab7165..8e212bf 100644 --- a/async_postgres/pg_connection/lifecycle.nim +++ b/async_postgres/pg_connection/lifecycle.nim @@ -132,6 +132,12 @@ proc connectToHost*( ## Dials `entry.hostaddr` when given (bypassing name resolution), otherwise ## `entry.host`; SSL certificate verification always uses `entry.host`. + # 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). # WARNING: This is vulnerable to MITM downgrade attacks. A network @@ -165,11 +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. + + var conn: PgConnection when hasChronos: let transport = @@ -584,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 812eda0..bdf44fb 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 @@ -12,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 @@ -24,6 +27,14 @@ elif hasAsyncDispatch: when defined(ssl): import std/[dynlib, openssl, tempfiles, os] +when hasChronos or (hasAsyncDispatch and defined(ssl)): + const PgAlpnProtocol = "postgresql" + ## ALPN protocol name required for `sslnegotiation=direct` (PostgreSQL 17+). + +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` # resolves to `std/asyncnet`'s overload, which only sets SNI — it never matches @@ -41,62 +52,43 @@ 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: [].} + 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 - - 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 + # 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 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 + 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 @@ -168,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 @@ -206,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 @@ -219,10 +255,149 @@ proc sniName*(sslHost: string, sslSni: bool): string = return "" sslHost +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) + + 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) + # 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 + # 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 = [PgAlpnProtocol], + ) + 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 = [PgAlpnProtocol], + ) + installX509Capture( + conn.x509Capture, conn.tlsStream.ccontext.eng, addr conn.serverCertDer + ) + await conn.tlsStream.handshake() + if direct: + assertAlpnPostgres(conn.tlsStream.getSelectedAlpnProtocol()) + conn.reader = conn.tlsStream.reader + conn.writer = conn.tlsStream.writer + conn.sslEnabled = true + elif hasAsyncDispatch: + when defined(ssl): + var ctx: SslContext + var tmpPath: string + 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 = CVerifyPeer, caFile = tmpPath) + else: + ctx = newContext(verifyMode = CVerifyNone) + + # 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, + "sslnegotiation=direct: libssl does not export SSL_CTX_set_alpn_protos", + ) + + 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 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: + assertAlpnPostgres(getSelectedAlpnOpenssl(conn.socket.sslHandle)) + conn.sslEnabled = true + # Extract server certificate DER for SCRAM-SHA-256-PLUS channel binding. + # 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: + 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: + # 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: + 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. `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 @@ -236,6 +411,11 @@ 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: + await establishTls(conn, sslHost, direct = true) + return + let sslReq = encodeSSLRequest() var respChar: char var extraBytesBuffered = false @@ -281,112 +461,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, 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..2c2ea76 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,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`). A raw zero-initialized + ## `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 @@ -666,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_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..fa16842 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.} @@ -769,6 +769,188 @@ 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 = 0 + + proc testBody() {.async.} = + let ms = startMockServer() + + proc serverHandler() {.async.} = + 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: + await closeClient(st) + except CatchableError: + discard + + 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 closeServer(ms) + await serverFut + + 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 + var alpnAdvertised = false + + proc testBody() {.async.} = + let ms = startMockServer() + + proc serverHandler() {.async.} = + let st = await ms.accept() + # 5-byte TLS record header: type(1) + version(2) + length(2). Reading + # the full record avoids racing packet boundaries under load/MTU splits. + try: + 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) + + 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 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 errMentionsDirect = false + var noAggregateMsg = false + + 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 + errMentionsDirect = "sslnegotiation=direct requires" in e.msg + noAggregateMsg = "Could not connect to any host" notin e.msg + + waitFor testBody() + check raised + check errMentionsDirect + check noAggregateMsg + proc sendAuthSasl(client: MockClient, mechanisms: seq[string]): Future[void] {.async.} = var body: seq[byte] = @[] body.addInt32(10) # AuthenticationSASL