Skip to content
Draft
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
10 changes: 6 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 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 @@ -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:
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 @@ -462,6 +473,7 @@ proc initConnConfig*(
password = "",
database = "",
sslMode = sslPrefer,
sslNegotiation = sslnPostgres,
sslRootCert = "",
channelBinding = cbPrefer,
applicationName = "",
Expand All @@ -487,6 +499,7 @@ proc initConnConfig*(
password: password,
database: database,
sslMode: sslMode,
sslNegotiation: sslNegotiation,
sslRootCert: sslRootCert,
channelBinding: channelBinding,
applicationName: applicationName,
Expand Down
268 changes: 165 additions & 103 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 @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading