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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -129,6 +132,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:
Expand Down
13 changes: 13 additions & 0 deletions async_postgres/pg_connection/dsn.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -476,6 +487,7 @@ proc initConnConfig*(
password = "",
database = "",
sslMode = sslPrefer,
sslNegotiation = sslnPostgres,
sslRootCert = "",
sslSni = true,
channelBinding = cbPrefer,
Expand Down Expand Up @@ -503,6 +515,7 @@ proc initConnConfig*(
password: password,
database: database,
sslMode: sslMode,
sslNegotiation: sslNegotiation,
sslRootCert: sslRootCert,
sslSni: sslSni,
channelBinding: channelBinding,
Expand Down
273 changes: 162 additions & 111 deletions async_postgres/pg_connection/ssl.nim
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions async_postgres/pg_connection/types.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Loading