diff --git a/.release-notes/add-ssl-preferred.md b/.release-notes/add-ssl-preferred.md new file mode 100644 index 0000000..0d2e657 --- /dev/null +++ b/.release-notes/add-ssl-preferred.md @@ -0,0 +1,22 @@ +## Add SSLPreferred mode + +`SSLPreferred` is a new SSL mode equivalent to PostgreSQL's `sslmode=prefer`. It attempts SSL negotiation when connecting and falls back to plaintext if the server refuses. A TLS handshake failure (server accepts but handshake fails) is a hard failure — the connection is not retried as plaintext. + +Use `SSLPreferred` when you want encryption if available but don't want to fail when connecting to servers that don't support SSL: + +```pony +use "ssl/net" + +let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) +end + +let session = Session( + ServerConnectInfo(auth, host, port, SSLPreferred(sslctx)), + DatabaseConnectInfo(user, password, database), + notify) +``` + +The existing `SSLRequired` mode is unchanged — it still aborts if the server refuses SSL. diff --git a/CLAUDE.md b/CLAUDE.md index e278c56..b7a7abf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,17 +34,19 @@ Managed via `corral`. `Session` actor is the main entry point. Constructor takes `ServerConnectInfo` (auth, host, service, ssl_mode) and `DatabaseConnectInfo` (user, password, database). Implements `lori.TCPConnectionActor` and `lori.ClientLifecycleEventReceiver`. The stored `ServerConnectInfo` is accessible via `server_connect_info()` for use by `_CancelSender`. State transitions via `_SessionState` interface with concrete states: ``` -_SessionUnopened --connect (no SSL)--> _SessionConnected -_SessionUnopened --connect (SSL)--> _SessionSSLNegotiating -_SessionUnopened --fail--> _SessionClosed -_SessionSSLNegotiating --'S'+TLS ok--> _SessionConnected -_SessionSSLNegotiating --'N'/fail--> _SessionClosed -_SessionConnected --MD5 auth ok--> _SessionLoggedIn -_SessionConnected --MD5 auth fail--> _SessionClosed -_SessionConnected --SASL challenge--> _SessionSCRAMAuthenticating -_SessionSCRAMAuthenticating --auth ok--> _SessionLoggedIn -_SessionSCRAMAuthenticating --auth fail--> _SessionClosed -_SessionLoggedIn --close--> _SessionClosed +_SessionUnopened --connect (no SSL)--> _SessionConnected +_SessionUnopened --connect (SSLRequired/Preferred)--> _SessionSSLNegotiating +_SessionUnopened --fail--> _SessionClosed +_SessionSSLNegotiating --'S'+TLS ok--> _SessionConnected +_SessionSSLNegotiating --'N' (SSLRequired)--> _SessionClosed +_SessionSSLNegotiating --'N' (SSLPreferred)--> _SessionConnected (plaintext fallback) +_SessionSSLNegotiating --TLS fail--> _SessionClosed +_SessionConnected --MD5 auth ok--> _SessionLoggedIn +_SessionConnected --MD5 auth fail--> _SessionClosed +_SessionConnected --SASL challenge--> _SessionSCRAMAuthenticating +_SessionSCRAMAuthenticating --auth ok--> _SessionLoggedIn +_SessionSCRAMAuthenticating --auth fail--> _SessionClosed +_SessionLoggedIn --close--> _SessionClosed ``` State behavior is composed via a trait hierarchy that mixes in capabilities and defaults: @@ -53,7 +55,7 @@ State behavior is composed via a trait hierarchy that mixes in capabilities and - `_AuthenticableState` / `_NotAuthenticableState` — can/can't authenticate - `_AuthenticatedState` / `_NotAuthenticated` — has/hasn't authenticated -`_SessionSSLNegotiating` is a standalone class (not using `_ConnectedState`) because it handles raw bytes — the server's SSL response is not a PostgreSQL protocol message, so `_ResponseParser` is not used. It mixes in `_NotConnectableState`, `_NotAuthenticableState`, and `_NotAuthenticated`. +`_SessionSSLNegotiating` is a standalone class (not using `_ConnectedState`) because it handles raw bytes — the server's SSL response is not a PostgreSQL protocol message, so `_ResponseParser` is not used. It mixes in `_NotConnectableState`, `_NotAuthenticableState`, and `_NotAuthenticated`. A `_fallback_on_refusal` field controls behavior when the server responds 'N': `true` for `SSLPreferred` (fall back to plaintext), `false` for `SSLRequired` (fire `pg_session_connection_failed`). TLS handshake failures always fire `pg_session_connection_failed` regardless of this flag. `_SessionSCRAMAuthenticating` handles the multi-step SCRAM-SHA-256 exchange after `_SessionConnected` receives an AuthSASL challenge. It mixes in `_ConnectedState` (for `on_received`/TCP write) and `_NotAuthenticated`. Fields store the client nonce, client-first-bare, password, and expected server signature across the exchange steps. @@ -114,7 +116,7 @@ Only one operation is in-flight at a time. The queue serializes execution. `quer - `ClientQueryError` trait — `SessionNeverOpened`, `SessionClosed`, `SessionNotAuthenticated`, `DataError` - `DatabaseConnectInfo` — val class grouping database authentication parameters (user, password, database). Passed to `Session.create()` alongside `ServerConnectInfo`. - `ServerConnectInfo` — val class grouping connection parameters (auth, host, service, ssl_mode). Passed to `Session.create()` as the first parameter. Also used by `_CancelSender`. -- `SSLMode` — union type `(SSLDisabled | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLRequired` wraps an `SSLContext val` for TLS negotiation. +- `SSLMode` — union type `(SSLDisabled | SSLPreferred | SSLRequired)`. `SSLDisabled` is the default (plaintext). `SSLPreferred` wraps an `SSLContext val` and attempts SSL with plaintext fallback on server refusal (`sslmode=prefer`). `SSLRequired` wraps an `SSLContext val` and aborts on server refusal. - `ErrorResponseMessage` — full PostgreSQL error with all standard fields - `AuthenticationFailureReason` = `(InvalidAuthenticationSpecification | InvalidPassword | ServerVerificationFailed | UnsupportedAuthenticationMethod)` @@ -133,7 +135,7 @@ In `_RowsBuilder._field_to_type()`: ### Query Cancellation -`_CancelSender` actor — fire-and-forget actor that sends a `CancelRequest` on a separate TCP connection. PostgreSQL requires cancel requests on a different connection from the one executing the query. No response is expected on the cancel connection — the result (if any) arrives as an `ErrorResponse` on the original session connection. When the session uses `SSLRequired`, the cancel connection performs SSL negotiation before sending the CancelRequest — mirroring the main session's connection setup. If the server refuses SSL or the TLS handshake fails, the cancel is silently abandoned. Created by `_SessionLoggedIn.cancel()` using the session's `ServerConnectInfo`, `backend_pid`, and `backend_secret_key`. Design: [discussion #88](https://github.com/ponylang/postgres/discussions/88). +`_CancelSender` actor — fire-and-forget actor that sends a `CancelRequest` on a separate TCP connection. PostgreSQL requires cancel requests on a different connection from the one executing the query. No response is expected on the cancel connection — the result (if any) arrives as an `ErrorResponse` on the original session connection. When the session uses `SSLRequired` or `SSLPreferred`, the cancel connection performs SSL negotiation before sending the CancelRequest — mirroring the main session's connection setup. For `SSLRequired`, if the server refuses SSL or the TLS handshake fails, the cancel is silently abandoned. For `SSLPreferred`, server refusal falls back to a plaintext cancel; TLS handshake failure still silently abandons. Created by `_SessionLoggedIn.cancel()` using the session's `ServerConnectInfo`, `backend_pid`, and `backend_secret_key`. Design: [discussion #88](https://github.com/ponylang/postgres/discussions/88). ### Mort Primitives @@ -145,11 +147,11 @@ Tests live in the main `postgres/` package (private test classes), organized acr **Conventions**: `_test.pony` contains shared helpers (`_ConnectionTestConfiguration` for env vars, `_ConnectTestNotify`/`_AuthenticateTestNotify` reused by other files). `_test_response_parser.pony` contains `_Incoming*TestMessage` builder classes that construct raw protocol bytes for mock servers across all test files. `_test_mock_message_reader.pony` contains `_MockMessageReader` for extracting complete PostgreSQL frontend messages from TCP data in mock servers. -**Ports**: Mock server tests use ports in the 7669–7706 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it. +**Ports**: Mock server tests use ports in the 7669–7710 range and 9667–9668. **Port 7680 is reserved by Windows** (Update Delivery Optimization) and will fail to bind on WSL2 — do not use it. ## Supported PostgreSQL Features -**SSL/TLS:** Optional SSL negotiation via `SSLRequired`. CVE-2021-23222 mitigated via `expect(1)` before SSLRequest. Design: [discussion #76](https://github.com/ponylang/postgres/discussions/76). +**SSL/TLS:** Optional SSL negotiation via `SSLRequired` (mandatory) or `SSLPreferred` (fallback to plaintext on server refusal). CVE-2021-23222 mitigated via `expect(1)` before SSLRequest. Design: [discussion #76](https://github.com/ponylang/postgres/discussions/76). **Authentication:** MD5 password and SCRAM-SHA-256. No SCRAM-SHA-256-PLUS (channel binding), Kerberos, GSS, or certificate auth. Design: [discussion #83](https://github.com/ponylang/postgres/discussions/83). diff --git a/examples/README.md b/examples/README.md index 8a04636..c01af15 100644 --- a/examples/README.md +++ b/examples/README.md @@ -18,6 +18,10 @@ Parameterized queries using `PreparedQuery`. Sends a query with typed parameters Named prepared statements using `Session.prepare()` and `NamedPreparedQuery`. Prepares a statement once, executes it twice with different parameters, then cleans up with `Session.close_statement()`. Shows how to implement `PrepareReceiver` for prepare lifecycle callbacks. +## ssl-preferred-query + +SSL-preferred query using `SSLPreferred`. Same workflow as `query` but with SSL negotiation that falls back to plaintext if the server refuses — equivalent to PostgreSQL's `sslmode=prefer`. Demonstrates the difference between `SSLPreferred` (best-effort encryption) and `SSLRequired` (mandatory encryption). Works with both SSL-enabled and non-SSL PostgreSQL servers. + ## ssl-query SSL-encrypted query using `SSLRequired`. Same workflow as `query` but with TLS negotiation enabled. Demonstrates how to create an `SSLContext`, wrap it in `SSLRequired`, and pass it to `Session`. Requires a PostgreSQL server configured to accept SSL connections. diff --git a/examples/ssl-preferred-query/ssl-preferred-query-example.pony b/examples/ssl-preferred-query/ssl-preferred-query-example.pony new file mode 100644 index 0000000..cdfb196 --- /dev/null +++ b/examples/ssl-preferred-query/ssl-preferred-query-example.pony @@ -0,0 +1,117 @@ +""" +SSL-preferred query using `SSLPreferred`. Attempts TLS negotiation when +connecting, but falls back to plaintext if the server refuses — equivalent to +PostgreSQL's `sslmode=prefer`. A TLS handshake failure (server accepts but +handshake fails) is NOT retried as plaintext. + +Use `SSLPreferred` when you want encryption if available but don't want to +fail if the server doesn't support it. Use `SSLRequired` when encryption is +mandatory and you'd rather fail than connect without it. + +Requires environment variables for server configuration. Works with both +SSL-enabled and non-SSL PostgreSQL servers. +""" +use "cli" +use "collections" +use "files" +use lori = "lori" +use "ssl/net" +// in your code this `use` statement would be: +// use "postgres" +use "../../postgres" + +actor Main + new create(env: Env) => + let server_info = ServerInfo(env.vars) + + let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let auth = lori.TCPConnectAuth(env.root) + Client(auth, server_info, sslctx, env.out) + +actor Client is (SessionStatusNotify & ResultReceiver) + let _session: Session + let _out: OutStream + + new create(auth: lori.TCPConnectAuth, info: ServerInfo, + sslctx: SSLContext val, out: OutStream) + => + _out = out + _session = Session( + ServerConnectInfo(auth, info.host, info.port, SSLPreferred(sslctx)), + DatabaseConnectInfo(info.username, info.password, info.database), + this) + + be close() => + _session.close() + + be pg_session_connected(session: Session) => + _out.print("Connected (SSL negotiation complete — may be encrypted or plaintext).") + + be pg_session_connection_failed(session: Session) => + _out.print("Connection failed.") + + be pg_session_authenticated(session: Session) => + _out.print("Authenticated.") + _out.print("Sending query....") + let q = SimpleQuery("SELECT 525600::text") + session.execute(q, this) + + be pg_session_authentication_failed( + s: Session, + reason: AuthenticationFailureReason) + => + _out.print("Failed to authenticate.") + + be pg_query_result(session: Session, result: Result) => + match result + | let r: ResultSet => + _out.print("ResultSet (" + r.rows().size().string() + " rows):") + for row in r.rows().values() do + for field in row.fields.values() do + _out.write(field.name + "=") + match field.value + | let v: String => _out.print(v) + | let v: I16 => _out.print(v.string()) + | let v: I32 => _out.print(v.string()) + | let v: I64 => _out.print(v.string()) + | let v: F32 => _out.print(v.string()) + | let v: F64 => _out.print(v.string()) + | let v: Bool => _out.print(v.string()) + | let v: Array[U8] val => + _out.print(v.size().string() + " bytes") + | None => _out.print("NULL") + end + end + end + | let r: RowModifying => + _out.print(r.command() + " " + r.impacted().string() + " rows") + | let r: SimpleResult => + _out.print("Query executed.") + end + close() + + be pg_query_failed(session: Session, query: Query, + failure: (ErrorResponseMessage | ClientQueryError)) + => + _out.print("Query failed.") + close() + +class val ServerInfo + let host: String + let port: String + let username: String + let password: String + let database: String + + new val create(vars: (Array[String] val | None)) => + let e = EnvVars(vars) + host = try e("POSTGRES_HOST")? else "127.0.0.1" end + port = try e("POSTGRES_PORT")? else "5432" end + username = try e("POSTGRES_USERNAME")? else "postgres" end + password = try e("POSTGRES_PASSWORD")? else "postgres" end + database = try e("POSTGRES_DATABASE")? else "postgres" end diff --git a/postgres/_cancel_sender.pony b/postgres/_cancel_sender.pony index 62ba275..908ee80 100644 --- a/postgres/_cancel_sender.pony +++ b/postgres/_cancel_sender.pony @@ -9,10 +9,12 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei connection — the result (if any) arrives as an ErrorResponse on the original session connection. - When `SSLRequired` is active, performs SSL negotiation before sending the - CancelRequest — mirroring what the main Session connection does. If the - server refuses SSL or the TLS handshake fails, the cancel is silently - abandoned (fire-and-forget semantics). + When `SSLRequired` or `SSLPreferred` is active, performs SSL negotiation + before sending the CancelRequest — mirroring what the main Session + connection does. For `SSLRequired`, if the server refuses SSL or the TLS + handshake fails, the cancel is silently abandoned. For `SSLPreferred`, if + the server refuses SSL ('N'), the cancel proceeds over plaintext; TLS + handshake failure still silently abandons. """ var _tcp_connection: lori.TCPConnection = lori.TCPConnection.none() let _process_id: I32 @@ -33,7 +35,7 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei match _info.ssl_mode | SSLDisabled => _send_cancel_and_close() - | let _: SSLRequired => + | let _: (SSLRequired | SSLPreferred) => // CVE-2021-23222 mitigation: expect exactly 1 byte for SSL response. try _tcp_connection.expect(1)? end _tcp_connection.send(_FrontendMessage.ssl_request()) @@ -43,16 +45,29 @@ actor _CancelSender is (lori.TCPConnectionActor & lori.ClientLifecycleEventRecei // Only called during SSL negotiation — server responds 'S' or 'N'. try if data(0)? == 'S' then + let ctx = match _info.ssl_mode + | let req: SSLRequired => req.ctx + | let pref: SSLPreferred => pref.ctx + else + _tcp_connection.close() + return + end + match _tcp_connection.start_tls(ctx, _info.host) + | None => None // Handshake started, wait for _on_tls_ready + | let _: lori.StartTLSError => + _tcp_connection.close() + end + elseif data(0)? == 'N' then match _info.ssl_mode - | let req: SSLRequired => - match _tcp_connection.start_tls(req.ctx, _info.host) - | None => None // Handshake started, wait for _on_tls_ready - | let _: lori.StartTLSError => - _tcp_connection.close() - end + | let _: SSLPreferred => + // SSLPreferred: fall back to plaintext cancel + try _tcp_connection.expect(0)? end + _send_cancel_and_close() + else + // SSLRequired or unexpected: silently give up + _tcp_connection.close() end else - // 'N' or junk — server refused SSL, silently give up _tcp_connection.close() end else diff --git a/postgres/_test.pony b/postgres/_test.pony index a62bd1c..7089794 100644 --- a/postgres/_test.pony +++ b/postgres/_test.pony @@ -97,6 +97,12 @@ actor \nodoc\ Main is TestList test(_TestSSLAuthenticate) test(_TestSSLQueryResults) test(_TestSSLRefused) + test(_TestSSLPreferredFallback) + test(_TestSSLPreferredSuccess) + test(_TestSSLPreferredTLSFailure) + test(_TestSSLPreferredCancelFallback) + test(_TestSSLPreferredWithSSLServer) + test(_TestSSLPreferredWithPlainServer) test(_TestFieldEqualityReflexive) test(_TestFieldEqualityStructural) test(_TestFieldEqualitySymmetric) diff --git a/postgres/_test_ssl.pony b/postgres/_test_ssl.pony index 2dab7a0..d85614b 100644 --- a/postgres/_test_ssl.pony +++ b/postgres/_test_ssl.pony @@ -463,3 +463,627 @@ class \nodoc\ iso _TestSSLRefused is UnitTest h.dispose_when_done(session) h.long_test(5_000_000_000) + +// SSLPreferred unit tests + +class \nodoc\ iso _TestSSLPreferredFallback is UnitTest + """ + Verifies that when using SSLPreferred and the server responds 'N' to an + SSLRequest, the session falls back to plaintext and successfully connects + and authenticates. + """ + fun name(): String => + "SSLPreferred/Fallback" + + fun apply(h: TestHelper) => + let host = "127.0.0.1" + let port = "7707" + + let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let listener = _SSLPreferredFallbackTestListener( + lori.TCPListenAuth(h.env.root), + host, + port, + h, + sslctx) + + h.dispose_when_done(listener) + h.long_test(5_000_000_000) + +actor \nodoc\ _SSLPreferredFallbackTestNotify is SessionStatusNotify + let _h: TestHelper + var _connected: Bool = false + var _authenticated: Bool = false + + new create(h: TestHelper) => + _h = h + + be pg_session_connected(s: Session) => + _connected = true + + be pg_session_authenticated(session: Session) => + _authenticated = true + session.close() + if _connected then + _h.complete(true) + else + _h.fail("Authenticated but never connected") + _h.complete(false) + end + + be pg_session_connection_failed(s: Session) => + _h.fail("Should not have gotten connection_failed with SSLPreferred") + _h.complete(false) + + be pg_session_shutdown(s: Session) => + if not _authenticated then + _h.fail("Unexpected shutdown before authentication") + _h.complete(false) + end + +actor \nodoc\ _SSLPreferredFallbackTestListener is lori.TCPListenerActor + var _tcp_listener: lori.TCPListener = lori.TCPListener.none() + let _server_auth: lori.TCPServerAuth + let _h: TestHelper + let _host: String + let _port: String + let _sslctx: SSLContext val + + new create(listen_auth: lori.TCPListenAuth, + host: String, + port: String, + h: TestHelper, + sslctx: SSLContext val) + => + _host = host + _port = port + _h = h + _sslctx = sslctx + _server_auth = lori.TCPServerAuth(listen_auth) + _tcp_listener = lori.TCPListener(listen_auth, host, port, this) + + fun ref _listener(): lori.TCPListener => + _tcp_listener + + fun ref _on_accept(fd: U32): _SSLPreferredFallbackTestServer => + _SSLPreferredFallbackTestServer(_server_auth, fd) + + fun ref _on_listening() => + Session( + ServerConnectInfo(lori.TCPConnectAuth(_h.env.root), _host, _port, + SSLPreferred(_sslctx)), + DatabaseConnectInfo("postgres", "postgres", "postgres"), + _SSLPreferredFallbackTestNotify(_h)) + + fun ref _on_listen_failure() => + _h.fail("Unable to listen") + _h.complete(false) + +actor \nodoc\ _SSLPreferredFallbackTestServer + is (lori.TCPConnectionActor & lori.ServerLifecycleEventReceiver) + """ + Mock server that responds 'N' to an SSLRequest (refusing SSL), then reads + the plaintext StartupMessage and sends AuthOk + ReadyForQuery. + """ + var _tcp_connection: lori.TCPConnection = lori.TCPConnection.none() + var _ssl_refused: Bool = false + let _reader: _MockMessageReader = _MockMessageReader + + new create(auth: lori.TCPServerAuth, fd: U32) => + _tcp_connection = lori.TCPConnection.server(auth, fd, this, this) + + fun ref _connection(): lori.TCPConnection => + _tcp_connection + + fun ref _on_received(data: Array[U8] iso) => + _reader.append(consume data) + _process() + + fun ref _process() => + if not _ssl_refused then + match _reader.read_startup_message() + | let _: Array[U8] val => + // Client sent SSLRequest — respond 'N' (refuse SSL) + let response: Array[U8] val = ['N'] + _tcp_connection.send(response) + _ssl_refused = true + _process() + end + else + match _reader.read_startup_message() + | let _: Array[U8] val => + // StartupMessage received over plaintext — send AuthOk + ReadyForQuery + let auth_ok = _IncomingAuthenticationOkTestMessage.bytes() + let ready = _IncomingReadyForQueryTestMessage('I').bytes() + _tcp_connection.send(auth_ok) + _tcp_connection.send(ready) + end + end + +class \nodoc\ iso _TestSSLPreferredSuccess is UnitTest + """ + Verifies that when using SSLPreferred and the server responds 'S' to an + SSLRequest, the TLS handshake completes and the session connects and + authenticates over SSL — same as SSLRequired when the server accepts. + """ + fun name(): String => + "SSLPreferred/Success" + + fun apply(h: TestHelper) ? => + let host = "127.0.0.1" + let port = "7708" + + let cert_path = FilePath(FileAuth(h.env.root), + "assets/test-cert.pem") + let key_path = FilePath(FileAuth(h.env.root), + "assets/test-key.pem") + + let client_sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let server_sslctx = recover val + SSLContext + .> set_cert(cert_path, key_path)? + .> set_client_verify(false) + .> set_server_verify(false) + end + + let listener = _SSLPreferredSuccessTestListener( + lori.TCPListenAuth(h.env.root), + host, + port, + h, + client_sslctx, + server_sslctx) + + h.dispose_when_done(listener) + h.long_test(5_000_000_000) + +actor \nodoc\ _SSLPreferredSuccessTestNotify is SessionStatusNotify + let _h: TestHelper + var _authenticated: Bool = false + + new create(h: TestHelper) => + _h = h + + be pg_session_connected(s: Session) => + None + + be pg_session_authenticated(session: Session) => + _authenticated = true + session.close() + _h.complete(true) + + be pg_session_connection_failed(s: Session) => + _h.fail("Connection failed during SSL negotiation") + _h.complete(false) + + be pg_session_shutdown(s: Session) => + if not _authenticated then + _h.fail("Unexpected shutdown before authentication") + _h.complete(false) + end + +actor \nodoc\ _SSLPreferredSuccessTestListener is lori.TCPListenerActor + var _tcp_listener: lori.TCPListener = lori.TCPListener.none() + let _server_auth: lori.TCPServerAuth + let _h: TestHelper + let _host: String + let _port: String + let _client_sslctx: SSLContext val + let _server_sslctx: SSLContext val + + new create(listen_auth: lori.TCPListenAuth, + host: String, + port: String, + h: TestHelper, + client_sslctx: SSLContext val, + server_sslctx: SSLContext val) + => + _host = host + _port = port + _h = h + _client_sslctx = client_sslctx + _server_sslctx = server_sslctx + _server_auth = lori.TCPServerAuth(listen_auth) + _tcp_listener = lori.TCPListener(listen_auth, host, port, this) + + fun ref _listener(): lori.TCPListener => + _tcp_listener + + fun ref _on_accept(fd: U32): _SSLSuccessTestServer => + _SSLSuccessTestServer(_server_auth, _server_sslctx, fd) + + fun ref _on_listening() => + Session( + ServerConnectInfo(lori.TCPConnectAuth(_h.env.root), _host, _port, + SSLPreferred(_client_sslctx)), + DatabaseConnectInfo("postgres", "postgres", "postgres"), + _SSLPreferredSuccessTestNotify(_h)) + + fun ref _on_listen_failure() => + _h.fail("Unable to listen") + _h.complete(false) + +class \nodoc\ iso _TestSSLPreferredTLSFailure is UnitTest + """ + Verifies that when using SSLPreferred and the server responds 'S' but uses + an incompatible TLS configuration causing the handshake to fail, the + session fires pg_session_connection_failed — NOT a fallback to plaintext. + TLS handshake failure is a hard failure regardless of SSL mode. + """ + fun name(): String => + "SSLPreferred/TLSFailure" + + fun apply(h: TestHelper) ? => + let host = "127.0.0.1" + let port = "7709" + + // Client requires TLS 1.3 minimum, server only offers TLS 1.2 max. + // This creates an incompatible TLS configuration that will cause the + // handshake to fail. + let client_sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + .> set_min_proto_version(Tls1u3Version())? + end + + let cert_path = FilePath(FileAuth(h.env.root), + "assets/test-cert.pem") + let key_path = FilePath(FileAuth(h.env.root), + "assets/test-key.pem") + + let server_sslctx = recover val + SSLContext + .> set_cert(cert_path, key_path)? + .> set_client_verify(false) + .> set_server_verify(false) + .> set_max_proto_version(Tls1u2Version())? + end + + let listener = _SSLPreferredTLSFailureTestListener( + lori.TCPListenAuth(h.env.root), + host, + port, + h, + client_sslctx, + server_sslctx) + + h.dispose_when_done(listener) + h.long_test(5_000_000_000) + +actor \nodoc\ _SSLPreferredTLSFailureTestNotify is SessionStatusNotify + let _h: TestHelper + + new create(h: TestHelper) => + _h = h + + be pg_session_connection_failed(s: Session) => + _h.complete(true) + + be pg_session_connected(s: Session) => + _h.fail("Should not have connected after TLS failure") + _h.complete(false) + + be pg_session_authenticated(session: Session) => + _h.fail("Should not have authenticated after TLS failure") + _h.complete(false) + +actor \nodoc\ _SSLPreferredTLSFailureTestListener is lori.TCPListenerActor + var _tcp_listener: lori.TCPListener = lori.TCPListener.none() + let _server_auth: lori.TCPServerAuth + let _h: TestHelper + let _host: String + let _port: String + let _client_sslctx: SSLContext val + let _server_sslctx: SSLContext val + + new create(listen_auth: lori.TCPListenAuth, + host: String, + port: String, + h: TestHelper, + client_sslctx: SSLContext val, + server_sslctx: SSLContext val) + => + _host = host + _port = port + _h = h + _client_sslctx = client_sslctx + _server_sslctx = server_sslctx + _server_auth = lori.TCPServerAuth(listen_auth) + _tcp_listener = lori.TCPListener(listen_auth, host, port, this) + + fun ref _listener(): lori.TCPListener => + _tcp_listener + + fun ref _on_accept(fd: U32): _SSLSuccessTestServer => + // Reuses _SSLSuccessTestServer — it responds 'S' and attempts TLS. + // The incompatible TLS configs cause the handshake to fail. + _SSLSuccessTestServer(_server_auth, _server_sslctx, fd) + + fun ref _on_listening() => + Session( + ServerConnectInfo(lori.TCPConnectAuth(_h.env.root), _host, _port, + SSLPreferred(_client_sslctx)), + DatabaseConnectInfo("postgres", "postgres", "postgres"), + _SSLPreferredTLSFailureTestNotify(_h)) + + fun ref _on_listen_failure() => + _h.fail("Unable to listen") + _h.complete(false) + +class \nodoc\ iso _TestSSLPreferredCancelFallback is UnitTest + """ + Verifies that when using SSLPreferred and the cancel connection's server + refuses SSL ('N'), the _CancelSender falls back to plaintext and sends a + valid CancelRequest. The main session uses SSLPreferred with a server that + accepts SSL, but the cancel connection encounters a refusal. + """ + fun name(): String => + "SSLPreferred/CancelFallback" + + fun apply(h: TestHelper) ? => + let host = "127.0.0.1" + let port = "7710" + + let cert_path = FilePath(FileAuth(h.env.root), + "assets/test-cert.pem") + let key_path = FilePath(FileAuth(h.env.root), + "assets/test-key.pem") + + let client_sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let server_sslctx = recover val + SSLContext + .> set_cert(cert_path, key_path)? + .> set_client_verify(false) + .> set_server_verify(false) + end + + let listener = _SSLPreferredCancelTestListener( + lori.TCPListenAuth(h.env.root), + host, + port, + h, + client_sslctx, + server_sslctx) + + h.dispose_when_done(listener) + h.long_test(5_000_000_000) + +actor \nodoc\ _SSLPreferredCancelTestListener is lori.TCPListenerActor + var _tcp_listener: lori.TCPListener = lori.TCPListener.none() + let _server_auth: lori.TCPServerAuth + let _h: TestHelper + let _host: String + let _port: String + let _client_sslctx: SSLContext val + let _server_sslctx: SSLContext val + var _connection_count: USize = 0 + + new create(listen_auth: lori.TCPListenAuth, + host: String, + port: String, + h: TestHelper, + client_sslctx: SSLContext val, + server_sslctx: SSLContext val) + => + _host = host + _port = port + _h = h + _client_sslctx = client_sslctx + _server_sslctx = server_sslctx + _server_auth = lori.TCPServerAuth(listen_auth) + _tcp_listener = lori.TCPListener(listen_auth, host, port, this) + + fun ref _listener(): lori.TCPListener => + _tcp_listener + + fun ref _on_accept(fd: U32): _SSLPreferredCancelTestServer => + _connection_count = _connection_count + 1 + _SSLPreferredCancelTestServer(_server_auth, _server_sslctx, fd, _h, + _connection_count > 1) + + fun ref _on_listening() => + let session = Session( + ServerConnectInfo(lori.TCPConnectAuth(_h.env.root), _host, _port, + SSLPreferred(_client_sslctx)), + DatabaseConnectInfo("postgres", "postgres", "postgres"), + _CancelTestClient(_h)) + _h.dispose_when_done(session) + + fun ref _on_listen_failure() => + _h.fail("Unable to listen") + _h.complete(false) + +actor \nodoc\ _SSLPreferredCancelTestServer + is (lori.TCPConnectionActor & lori.ServerLifecycleEventReceiver) + """ + Mock server that handles two connections: the first (main session) accepts + SSL and authenticates; the second (cancel sender) refuses SSL ('N') so the + cancel falls back to plaintext, then verifies the CancelRequest. + """ + var _tcp_connection: lori.TCPConnection = lori.TCPConnection.none() + let _sslctx: SSLContext val + let _h: TestHelper + let _is_cancel_connection: Bool + var _ssl_started: Bool = false + var _ssl_refused: Bool = false + var _authed: Bool = false + let _reader: _MockMessageReader = _MockMessageReader + + new create(auth: lori.TCPServerAuth, sslctx: SSLContext val, fd: U32, + h: TestHelper, is_cancel: Bool) + => + _sslctx = sslctx + _h = h + _is_cancel_connection = is_cancel + _tcp_connection = lori.TCPConnection.server(auth, fd, this, this) + + fun ref _connection(): lori.TCPConnection => + _tcp_connection + + fun ref _on_received(data: Array[U8] iso) => + _reader.append(consume data) + _process() + + fun ref _process() => + if _is_cancel_connection then + if not _ssl_refused then + match _reader.read_startup_message() + | let _: Array[U8] val => + // Cancel connection: refuse SSL so _CancelSender falls back + let response: Array[U8] val = ['N'] + _tcp_connection.send(response) + _ssl_refused = true + _process() + end + else + match _reader.read_startup_message() + | let msg: Array[U8] val => + // Verify CancelRequest: 16 bytes total + // Int32(16) Int32(80877102) Int32(pid=12345) Int32(key=67890) + if msg.size() != 16 then + _h.fail("CancelRequest should be 16 bytes, got " + + msg.size().string()) + _h.complete(false) + return + end + + try + if (msg(0)? != 0) or (msg(1)? != 0) or (msg(2)? != 0) + or (msg(3)? != 16) then + _h.fail("CancelRequest length field is incorrect") + _h.complete(false) + return + end + + if (msg(4)? != 4) or (msg(5)? != 210) or (msg(6)? != 22) + or (msg(7)? != 46) then + _h.fail("CancelRequest magic number is incorrect") + _h.complete(false) + return + end + + if (msg(8)? != 0) or (msg(9)? != 0) or (msg(10)? != 48) + or (msg(11)? != 57) then + _h.fail("CancelRequest process_id is incorrect") + _h.complete(false) + return + end + + if (msg(12)? != 0) or (msg(13)? != 1) or (msg(14)? != 9) + or (msg(15)? != 50) then + _h.fail("CancelRequest secret_key is incorrect") + _h.complete(false) + return + end + + _h.complete(true) + else + _h.fail("Error reading CancelRequest bytes") + _h.complete(false) + end + end + end + else + // Main session connection + if not _ssl_started then + match _reader.read_startup_message() + | let _: Array[U8] val => + // SSLRequest — respond 'S' and upgrade to TLS + let response: Array[U8] val = ['S'] + _tcp_connection.send(response) + match _tcp_connection.start_tls(_sslctx) + | None => _ssl_started = true + | let _: lori.StartTLSError => + _tcp_connection.close() + end + end + elseif not _authed then + match _reader.read_startup_message() + | let _: Array[U8] val => + _authed = true + let auth_ok = _IncomingAuthenticationOkTestMessage.bytes() + let bkd = _IncomingBackendKeyDataTestMessage(12345, 67890).bytes() + let ready = _IncomingReadyForQueryTestMessage('I').bytes() + let combined: Array[U8] val = recover val + let arr = Array[U8] + arr.append(auth_ok) + arr.append(bkd) + arr.append(ready) + arr + end + _tcp_connection.send(combined) + end + end + // After auth, receive query data and hold (don't respond) + end + +// SSLPreferred integration tests + +class \nodoc\ iso _TestSSLPreferredWithSSLServer is UnitTest + """ + Verifies that connecting with SSLPreferred to an SSL-enabled PostgreSQL + server results in a successful SSL connection and authentication. + """ + fun name(): String => + "integration/SSLPreferred/WithSSLServer" + + fun apply(h: TestHelper) => + let info = _ConnectionTestConfiguration(h.env.vars) + + let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let session = Session( + ServerConnectInfo(lori.TCPConnectAuth(h.env.root), info.ssl_host, + info.ssl_port, SSLPreferred(sslctx)), + DatabaseConnectInfo(info.username, info.password, info.database), + _AuthenticateTestNotify(h, true)) + + h.dispose_when_done(session) + h.long_test(5_000_000_000) + +class \nodoc\ iso _TestSSLPreferredWithPlainServer is UnitTest + """ + Verifies that connecting with SSLPreferred to a PostgreSQL server that + does not support SSL results in a successful plaintext fallback connection + and authentication. + """ + fun name(): String => + "integration/SSLPreferred/WithPlainServer" + + fun apply(h: TestHelper) => + let info = _ConnectionTestConfiguration(h.env.vars) + + let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + + let session = Session( + ServerConnectInfo(lori.TCPConnectAuth(h.env.root), info.host, + info.port, SSLPreferred(sslctx)), + DatabaseConnectInfo(info.username, info.password, info.database), + _AuthenticateTestNotify(h, true)) + + h.dispose_when_done(session) + h.long_test(5_000_000_000) diff --git a/postgres/postgres.pony b/postgres/postgres.pony index b6a8c44..3101012 100644 --- a/postgres/postgres.pony +++ b/postgres/postgres.pony @@ -58,7 +58,14 @@ Implement the ones you need: ## SSL/TLS -Pass `SSLRequired` to enable SSL negotiation: +Two SSL modes are available: + +* **`SSLRequired`** — aborts if the server refuses SSL. Use when + encryption is mandatory. +* **`SSLPreferred`** — attempts SSL, falls back to plaintext if the + server refuses. Equivalent to PostgreSQL's `sslmode=prefer`. A TLS + handshake failure (server accepts but handshake fails) is NOT retried + as plaintext — `pg_session_connection_failed` fires. ```pony use "ssl/net" @@ -69,15 +76,22 @@ let sslctx = recover val .> set_authority(FilePath(FileAuth(env.root), "/path/to/ca.pem"))? end +// Require SSL — fail if server refuses let session = Session( ServerConnectInfo( lori.TCPConnectAuth(env.root), "localhost", "5432", SSLRequired(sslctx)), DatabaseConnectInfo("myuser", "mypassword", "mydb"), MyNotify(env)) -``` -If the server refuses SSL, `pg_session_connection_failed` fires. +// Prefer SSL — fall back to plaintext if server refuses +let session2 = Session( + ServerConnectInfo( + lori.TCPConnectAuth(env.root), "localhost", "5432", + SSLPreferred(sslctx)), + DatabaseConnectInfo("myuser", "mypassword", "mydb"), + MyNotify(env)) +``` ## Executing Queries @@ -249,7 +263,7 @@ supported. * Simple and extended query protocols * Parameterized queries (unnamed and named prepared statements) -* SSL/TLS via `SSLRequired` +* SSL/TLS via `SSLRequired` and `SSLPreferred` * MD5 and SCRAM-SHA-256 authentication * Transaction status tracking (`TransactionStatus`) * LISTEN/NOTIFY notifications diff --git a/postgres/session.pony b/postgres/session.pony index 2bca6d5..c3ad2c7 100644 --- a/postgres/session.pony +++ b/postgres/session.pony @@ -274,22 +274,31 @@ class ref _SessionSSLNegotiating or for the TLS handshake to complete. This state handles raw bytes — the server's response to SSLRequest is not a standard PostgreSQL protocol message, so _ResponseParser is not used. + + `_fallback_on_refusal` controls behavior when the server responds 'N': if + true (`SSLPreferred`), the session falls back to plaintext; if false + (`SSLRequired`), the session fires `pg_session_connection_failed`. TLS + handshake failures always fire `pg_session_connection_failed` regardless + of this flag. """ let _notify: SessionStatusNotify let _database_connect_info: DatabaseConnectInfo let _ssl_ctx: SSLContext val let _host: String + let _fallback_on_refusal: Bool var _handshake_started: Bool = false new ref create(notify': SessionStatusNotify, database_connect_info': DatabaseConnectInfo, ssl_ctx': SSLContext val, - host': String) + host': String, + fallback_on_refusal': Bool) => _notify = notify' _database_connect_info = database_connect_info' _ssl_ctx = ssl_ctx' _host = host' + _fallback_on_refusal = fallback_on_refusal' fun ref send_ssl_request(s: Session ref) => let msg = _FrontendMessage.ssl_request() @@ -313,7 +322,11 @@ class ref _SessionSSLNegotiating _connection_failed(s) end elseif response == 'N' then - _connection_failed(s) + if _fallback_on_refusal then + _proceed_to_connected(s) + else + _connection_failed(s) + end else _shutdown(s) end @@ -322,6 +335,9 @@ class ref _SessionSSLNegotiating end fun ref on_tls_ready(s: Session ref) => + _proceed_to_connected(s) + + fun ref _proceed_to_connected(s: Session ref) => // Reset expect from 1 (set during SSLRequest) to 0 (deliver all available // bytes). Critical: lori preserves the expect(1) value across start_tls() // via _ssl_expect. Without this reset, decrypted data would be delivered @@ -2195,18 +2211,26 @@ trait _ConnectableState is _UnconnectedState s.state = _SessionConnected(notify(), database_connect_info()) notify().pg_session_connected(s) _send_startup_message(s) + | let pref: SSLPreferred => + _start_ssl_negotiation(s, pref.ctx, true) | let req: SSLRequired => - // Set expect(1) BEFORE sending SSLRequest so lori delivers exactly - // one byte per _on_received call. Any MITM-injected bytes stay in - // lori's internal buffer, causing start_tls() to return - // StartTLSNotReady (CVE-2021-23222 mitigation). - try s._connection().expect(1)? end - let st = _SessionSSLNegotiating( - notify(), database_connect_info(), req.ctx, host()) - s.state = st - st.send_ssl_request(s) + _start_ssl_negotiation(s, req.ctx, false) end + fun _start_ssl_negotiation(s: Session ref, ctx: SSLContext val, + fallback_on_refusal: Bool) + => + // Set expect(1) BEFORE sending SSLRequest so lori delivers exactly + // one byte per _on_received call. Any MITM-injected bytes stay in + // lori's internal buffer, causing start_tls() to return + // StartTLSNotReady (CVE-2021-23222 mitigation). + try s._connection().expect(1)? end + let st = _SessionSSLNegotiating( + notify(), database_connect_info(), ctx, host(), + fallback_on_refusal) + s.state = st + st.send_ssl_request(s) + fun on_failure(s: Session ref) => s.state = _SessionClosed notify().pg_session_connection_failed(s) diff --git a/postgres/ssl_mode.pony b/postgres/ssl_mode.pony index 00ce619..a85e7c7 100644 --- a/postgres/ssl_mode.pony +++ b/postgres/ssl_mode.pony @@ -5,6 +5,33 @@ primitive SSLDisabled Do not use SSL. The connection is plaintext. This is the default. """ +class val SSLPreferred + """ + Prefer SSL but fall back to plaintext if the server refuses. The driver + sends an SSLRequest during connection setup. If the server responds 'S', + the TLS handshake proceeds normally. If the server responds 'N' (refusing + SSL), the connection continues as plaintext — equivalent to PostgreSQL's + `sslmode=prefer`. + + A TLS handshake failure (server accepts but the handshake itself fails) + is NOT retried as plaintext — `pg_session_connection_failed` fires, + matching PostgreSQL's `sslmode=prefer` behavior. + + The `SSLContext` controls certificate and cipher configuration. Users must + `use "ssl/net"` in their own code to create an `SSLContext val`: + + let sslctx = recover val + SSLContext + .> set_client_verify(false) + .> set_server_verify(false) + end + SSLPreferred(sslctx) + """ + let ctx: SSLContext val + + new val create(ctx': SSLContext val) => + ctx = ctx' + class val SSLRequired """ Require SSL. The driver sends an SSLRequest during connection setup and @@ -26,4 +53,4 @@ class val SSLRequired new val create(ctx': SSLContext val) => ctx = ctx' -type SSLMode is (SSLDisabled | SSLRequired) +type SSLMode is (SSLDisabled | SSLPreferred | SSLRequired)