Skip to content

PostgreSQL Transport Security — TLS (OpenSSL) and GSSAPI Encryption

Contents:

A database server exposes a network endpoint that carries credentials, queries, and result data — all of which an adversary on the path between client and server would dearly like to read or tamper with. Transport security is the layer that turns the raw, cleartext byte stream of a TCP connection into a channel with three guarantees, named explicitly in the header comment of be-secure.c: confidentiality (an eavesdropper learns nothing), message integrity (tampering is detected), and endpoint authentication (the client can verify it is talking to the real server, and optionally vice versa). These three properties are exactly the security goals that the TLS and GSSAPI/Kerberos protocol families were designed to provide.

Three architectural questions define the design space:

  1. Where does the security layer sit relative to the application protocol? The clean answer — and the one PostgreSQL takes — is strictly beneath it: the FE/BE message framing (type byte + length word, see postgres-wire-protocol.md) is identical whether or not the bytes are encrypted, because encryption is applied to the byte stream after the message layer has produced it. This is the classic OSI “session over presentation” layering: the application code calls read/write on an abstract channel and never learns whether a cipher sits underneath.

  2. How is the secure channel negotiated? A client cannot simply assume the server speaks TLS — the server might have SSL disabled, or be reached over a Unix socket where TLS is pointless. So the protocol needs an in-band negotiation: the client asks “may I start TLS?”, the server answers yes or no, and only on “yes” does the cryptographic handshake begin. The alternative, implicit/direct TLS (the cryptographic handshake is the very first thing on the wire, as in HTTPS), trades the round-trip of negotiation for the inability to multiplex cleartext and encrypted clients on one port. PostgreSQL historically used only the negotiated form; PG17 added an opt-in direct-TLS path discriminated by ALPN.

  3. Which cryptographic stack? TLS (via OpenSSL) is the dominant choice: X.509 certificates, asymmetric key exchange, a negotiated symmetric cipher. GSSAPI/Kerberos is the alternative favoured in enterprise single-sign-on environments: the same Kerberos ticket that authenticates the user can also establish a session key, so the encryption and the authentication share one mechanism. PostgreSQL supports both, mutually exclusively, on the same listening socket.

Database System Concepts (Silberschatz et al.), in its chapter on application security, frames encryption of data “in flight” as orthogonal to access control: authentication answers who are you, authorization answers what may you do, and transport encryption answers can anyone else read this. Database Internals (Petrov), in its discussion of node communication, notes that production systems uniformly push the cryptographic concern into a thin shim beneath the message codec so that the higher layers — the query protocol, the replication protocol — remain cipher-agnostic. PostgreSQL’s secure_read/secure_write pair is precisely that shim.

A subtle but security-critical theme runs through this layer: the boundary between cleartext and ciphertext must be airtight. If the server ever processes a byte that arrived before the handshake completed as though it were post-handshake application data, a man-in-the-middle can inject cleartext that the application mistakes for authenticated traffic. PostgreSQL guards this boundary explicitly (the “received unencrypted data after SSL request” FATAL), and that guard is a direct consequence of the threat model.

This section names the recurring engineering patterns that client-server databases adopt for transport security, so PostgreSQL’s choices read as selections within a shared space.

Every engine that supports optional encryption introduces an indirection layer: the message-framing code calls channel_read(conn, buf, len) rather than recv(2) directly, and channel_read dispatches — at runtime, per connection — to a raw-socket path, a TLS path, or some other secure path. The dispatch is cheap (a couple of branches on a per-connection flag) and it means the entire message-parsing and message-building machinery is written exactly once, against an abstract channel. PostgreSQL’s secure_read is a canonical instance: a branch on port->ssl_in_use, then port->gss->enc, then the raw fallback.

In-band negotiation with a one-byte verdict

Section titled “In-band negotiation with a one-byte verdict”

The negotiated-TLS pattern is nearly universal: the client sends a fixed sentinel (“StartTLS”, SSLRequest, MySQL’s SSL capability flag), and the server replies with a minimal yes/no token before any handshake bytes flow. The verdict has to be a single, unambiguous byte (or small fixed field) sent in cleartext, because at that instant neither side has a cipher yet. PostgreSQL answers SSLRequest with exactly one byte, 'S' (yes) or 'N' (no); GSSENCRequest with 'G' or 'N'.

Handshake over an abstracted socket (the BIO pattern)

Section titled “Handshake over an abstracted socket (the BIO pattern)”

TLS libraries do not assume they own the file descriptor. OpenSSL’s BIO abstraction lets the application interpose its own send/recv functions, so that interrupt handling, non-blocking semantics, and platform quirks (Windows signal handling) stay under the database’s control rather than the library’s. The database supplies a BIO whose read/write methods are thin wrappers over its own raw-socket primitives; OpenSSL then drives the handshake and record encryption through that BIO. PostgreSQL’s port_bio_read/port_bio_write (calling secure_raw_read/secure_raw_write) are exactly this.

When the secure layer is TLS, the same handshake that establishes confidentiality can also authenticate one or both endpoints via X.509 certificates. The server always presents a certificate; the client may be asked for one (CertificateRequest). The server then extracts an identity (Common Name, full Distinguished Name) from the client certificate, which an authentication method (cert in pg_hba.conf) can map to a database role. This couples transport security to authentication — but only optionally, and the coupling is mediated by pg_hba.conf, not hard-wired.

Channel binding ties auth to the transport

Section titled “Channel binding ties auth to the transport”

A SCRAM-SHA-256 authentication exchange can be bound to the specific TLS channel underneath it (RFC 5929 tls-server-end-point), by mixing a hash of the server’s certificate into the authentication proof. This defeats a man-in-the-middle who terminates TLS and re-originates it: the relayed authentication proof no longer matches the attacker’s certificate. The transport layer must therefore expose “give me the hash of the server cert, in the algorithm RFC 5929 prescribes” — PostgreSQL’s be_tls_get_certificate_hash.

Length-prefixed framing for non-TLS encryption

Section titled “Length-prefixed framing for non-TLS encryption”

When the secure layer is not TLS — e.g. GSSAPI, which hands the application opaque “wrapped” tokens rather than managing a record stream — the database must frame those tokens itself. The universal answer is a 4-byte length prefix per token, so the receiver knows exactly how many bytes to collect before handing a complete token to the crypto library. PostgreSQL’s GSSAPI path does exactly this, with a hard PQ_GSS_MAX_PACKET_SIZE cap so a malicious peer cannot demand an unbounded allocation.

Theory / conventionPostgreSQL name
Dispatch shim beneath the codecsecure_read / secure_write in be-secure.c
Raw-socket primitivesecure_raw_read / secure_raw_write (recv/send)
In-band TLS negotiation requestNEGOTIATE_SSL_CODE (SSLRequest), one-byte 'S'/'N' reply
In-band GSS negotiation requestNEGOTIATE_GSS_CODE (GSSENCRequest), one-byte 'G'/'N' reply
Direct/implicit TLSProcessSSLStartup sniffs 0x16 TLS record byte; ALPN postgresql
TLS handshakebe_tls_open_serverSSL_accept
Custom socket BIOport_bio_read / port_bio_write / ssl_set_port_bio
TLS record I/Obe_tls_read (SSL_read) / be_tls_write (SSL_write)
Server cert / key loadbe_tls_init (SSL_CTX_use_certificate_chain_file, …)
Client cert verify callbackverify_cb, set via SSL_CTX_set_verify
Peer identity extractionport->peer_cn, port->peer_dn in be_tls_open_server
Channel-binding hashbe_tls_get_certificate_hash (RFC 5929)
GSSAPI transport encryptionbe_gssapi_read / be_gssapi_write (gss_unwrap/gss_wrap)
GSSAPI session establishmentsecure_open_gssapi (gss_accept_sec_context)
GSS packet capPQ_GSS_MAX_PACKET_SIZE (16 kB), PQ_GSS_AUTH_BUFFER_SIZE (64 kB)

PostgreSQL splits transport security across three source files with a clean responsibility boundary:

  • be-secure.c — the cipher-agnostic shim. It owns secure_read/secure_write (the dispatch), secure_raw_read/secure_raw_write (the recv/send primitives every cipher ultimately calls), and the thin secure_open_server/secure_close wrappers. This file compiles even in a build with neither SSL nor GSS — the dispatch simply collapses to the raw path. It is the only file the wire-protocol layer (pqcomm.c) knows about; pqcomm.c never includes an OpenSSL or GSSAPI header.

  • be-secure-openssl.c — everything TLS. Context setup (be_tls_init), per-connection handshake (be_tls_open_server), the custom BIO, record I/O (be_tls_read/be_tls_write), client-certificate handling, ALPN, channel binding, and a pile of accessor functions (be_tls_get_version, be_tls_get_cipher, …) that feed pg_stat_ssl and the ssl_* SQL functions.

  • be-secure-gssapi.c — everything GSSAPI transport encryption. The length-prefixed packet framing (be_gssapi_read/be_gssapi_write), the session-establishment loop (secure_open_gssapi), and the accessors (be_gssapi_get_enc, be_gssapi_get_princ, …).

The negotiation that chooses among these lives one layer up, in backend_startup.c (ProcessStartupPacket, ProcessSSLStartup) — covered in postgres-wire-protocol.md for the startup-packet mechanics; here we focus on the SSL/GSS branches specifically.

The dispatch shim: secure_read / secure_write

Section titled “The dispatch shim: secure_read / secure_write”

The whole architecture hinges on one runtime branch. secure_read chooses its backend from two per-connection flags and falls through to the raw socket:

// secure_read — src/backend/libpq/be-secure.c
retry:
#ifdef USE_SSL
waitfor = 0;
if (port->ssl_in_use)
n = be_tls_read(port, ptr, len, &waitfor);
else
#endif
#ifdef ENABLE_GSS
if (port->gss && port->gss->enc)
{
n = be_gssapi_read(port, ptr, len);
waitfor = WL_SOCKET_READABLE;
}
else
#endif
{
n = secure_raw_read(port, ptr, len);
waitfor = WL_SOCKET_READABLE;
}

secure_write mirrors this exactly. The flags are mutually exclusive: a connection is either TLS, or GSS-encrypted, or cleartext — never two at once, because the negotiation logic (below) sets ssl_done/gss_done so that accepting one rejects the other.

The shim also owns the blocking discipline. When the chosen backend returns EWOULDBLOCK/EAGAIN on a blocking-mode Port, secure_read parks on the wait-event set (FeBeWaitSet) until the socket is readable again, re-checking for postmaster death so a client backend doesn’t linger after the postmaster has gone:

// secure_read — src/backend/libpq/be-secure.c
if (n < 0 && !port->noblock && (errno == EWOULDBLOCK || errno == EAGAIN))
{
WaitEvent event;
ModifyWaitEvent(FeBeWaitSet, FeBeWaitSetSocketPos, waitfor, NULL);
WaitEventSetWait(FeBeWaitSet, -1, &event, 1, WAIT_EVENT_CLIENT_READ);
if (event.events & WL_POSTMASTER_DEATH)
ereport(FATAL, (errcode(ERRCODE_ADMIN_SHUTDOWN),
errmsg("terminating connection due to unexpected postmaster exit")));
if (event.events & WL_LATCH_SET)
{ ResetLatch(MyLatch); ProcessClientReadInterrupt(true); }
goto retry;
}

Note the waitfor value: for TLS, be_tls_read sets it to WL_SOCKET_READABLE or WL_SOCKET_WRITEABLE, because a SSL_read may need to write (a TLS renegotiation/post-handshake message). The raw and GSS paths always wait for readability. This is why waitfor is an out-parameter of be_tls_read but a constant for the others.

secure_raw_read / secure_raw_write: the floor

Section titled “secure_raw_read / secure_raw_write: the floor”

At the bottom of every path is a plain recv/send. The one subtlety: secure_raw_read first drains any bytes that were buffered before the secure layer was set up. During SSL negotiation the server may have already read a few bytes past the SSLRequest; secure_open_server stashes them in port->raw_buf, and secure_raw_read returns them before ever touching the socket — so OpenSSL’s first handshake read sees exactly the bytes that followed the request.

// secure_raw_read — src/backend/libpq/be-secure.c
if (port->raw_buf_remaining > 0)
{
if (len > port->raw_buf_remaining)
len = port->raw_buf_remaining;
memcpy(ptr, port->raw_buf + port->raw_buf_consumed, len);
port->raw_buf_consumed += len;
port->raw_buf_remaining -= len;
return len;
}
n = recv(port->sock, ptr, len, 0);
return n;

Before the regular startup packet, a v3 client may send one of two special 4-byte request codes (they are protocol “versions” reserved for this purpose):

// special request codes — src/include/libpq/pqcomm.h
#define CANCEL_REQUEST_CODE PG_PROTOCOL(1234,5678)
#define NEGOTIATE_SSL_CODE PG_PROTOCOL(1234,5679)
#define NEGOTIATE_GSS_CODE PG_PROTOCOL(1234,5680)

ProcessStartupPacket recognises these and replies with a single byte, written in cleartext through secure_write (which, at this point, is still the raw path). For SSL:

// ProcessStartupPacket (SSL branch) — src/backend/tcop/backend_startup.c
if (proto == NEGOTIATE_SSL_CODE && !ssl_done)
{
char SSLok;
#ifdef USE_SSL
if (!LoadedSSL || port->laddr.addr.ss_family == AF_UNIX || port->ssl_in_use)
SSLok = 'N';
else
SSLok = 'S'; /* Support for SSL */
#else
SSLok = 'N'; /* No support for SSL */
#endif
while (secure_write(port, &SSLok, 1) != 1) { ... }
#ifdef USE_SSL
if (SSLok == 'S' && secure_open_server(port) == -1)
return STATUS_ERROR;
#endif
...
ssl_done = true;
if (SSLok == 'S')
gss_done = true; /* SSL chosen → GSS no longer offered */
goto retry;
}

The GSS branch is structurally identical, answering 'G' or 'N' and then calling secure_open_gssapi. The client may try the two in either order; the ssl_done/gss_done flags ensure that accepting one closes the door on the other, and that a rejected request lets the client fall back to the other mechanism. After a successful handshake the client re-sends the real startup packet — now over the encrypted channel — and goto retry loops back to read it.

The man-in-the-middle guard fires right after the handshake: if any bytes are already buffered (they would have arrived before the cipher was established, hence cleartext), the server aborts the connection:

// ProcessStartupPacket — src/backend/tcop/backend_startup.c
if (pq_buffer_remaining_data() > 0)
ereport(FATAL,
(errcode(ERRCODE_PROTOCOL_VIOLATION),
errmsg("received unencrypted data after SSL request"),
errdetail("This could be either a client-software bug or evidence of an attempted man-in-the-middle attack.")));

Direct TLS (PG17+): sniffing the record byte

Section titled “Direct TLS (PG17+): sniffing the record byte”

PostgreSQL also accepts a direct TLS connection where the very first byte is a TLS handshake record rather than an SSLRequest. ProcessSSLStartup peeks the first byte; the TLS ContentType for a handshake record is 0x16, which can never be the high byte of a legitimate PostgreSQL startup length (that would imply a packet hundreds of MB long):

// ProcessSSLStartup — src/backend/tcop/backend_startup.c
firstbyte = pq_peekbyte();
if (firstbyte != 0x16)
return STATUS_OK; /* not a TLS handshake; normal startup */
#ifdef USE_SSL
if (!LoadedSSL || port->laddr.addr.ss_family == AF_UNIX)
goto reject;
if (secure_open_server(port) == -1)
goto reject;

Direct-TLS connections are required to negotiate the postgresql ALPN protocol (the check at the end of ProcessSSLStartup), which is how the server distinguishes a deliberate PostgreSQL direct-TLS client from some other protocol that happened to open TLS on the port.

flowchart TD
    A["client connects<br/>(TCP accept)"] --> B{"first bytes?"}
    B -->|"SSLRequest code"| C["reply 'S'/'N'<br/>(cleartext, 1 byte)"]
    B -->|"0x16 TLS record"| D["ProcessSSLStartup<br/>direct TLS"]
    B -->|"startup packet"| Z["no encryption<br/>→ authentication"]
    C -->|"'S'"| E["secure_open_server"]
    D --> E
    E --> F["be_tls_open_server"]
    F --> G["SSL_new + ssl_set_port_bio<br/>(custom BIO over recv/send)"]
    G --> H["SSL_accept loop<br/>(WANT_READ/WANT_WRITE → wait)"]
    H --> I["ALPN check<br/>(SSL_get0_alpn_selected)"]
    I --> J["extract peer cert<br/>CN / DN / valid flag"]
    J --> K["ssl_in_use = true"]
    K --> L["re-read startup packet<br/>over encrypted channel"]
    L --> Z2["authentication<br/>(pg_hba, channel binding)"]

be_tls_open_server allocates the per-connection SSL, installs the custom BIO, and drives SSL_accept in a loop that parks on the socket whenever OpenSSL returns WANT_READ/WANT_WRITE:

// be_tls_open_server — src/backend/libpq/be-secure-openssl.c
SSL_CTX_set_alpn_select_cb(SSL_context, alpn_cb, port);
if (!(port->ssl = SSL_new(SSL_context))) { ... }
if (!ssl_set_port_bio(port)) { ... }
port->ssl_in_use = true;
aloop:
ERR_clear_error();
r = SSL_accept(port->ssl);
if (r <= 0)
{
err = SSL_get_error(port->ssl, r);
switch (err)
{
case SSL_ERROR_WANT_READ:
case SSL_ERROR_WANT_WRITE:
(void) WaitLatchOrSocket(NULL, waitfor, port->sock, 0,
WAIT_EVENT_SSL_OPEN_SERVER);
goto aloop;
...
}
return -1;
}

The handshake’s SSL_ERROR_SSL branch (elided above) translates a family of OpenSSL reason codes (SSL_R_UNSUPPORTED_PROTOCOL, SSL_R_WRONG_VERSION_NUMBER, …) into a user-facing hint about the ssl_min_protocol_version/ssl_max_protocol_version range — a small but telling piece of operability engineering.

The custom BIO: handing OpenSSL our recv/send

Section titled “The custom BIO: handing OpenSSL our recv/send”

OpenSSL never touches the socket directly. ssl_set_port_bio builds a BIO whose data pointer is the Port and whose read/write methods funnel into the raw primitives:

// port_bio_read — src/backend/libpq/be-secure-openssl.c
static int
port_bio_read(BIO *h, char *buf, int size)
{
int res = 0;
Port *port = (Port *) BIO_get_data(h);
if (buf != NULL)
{
res = secure_raw_read(port, buf, size);
BIO_clear_retry_flags(h);
port->last_read_was_eof = res == 0;
if (res <= 0)
if (errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN)
BIO_set_retry_read(h);
}
return res;
}

Routing every byte through secure_raw_read means TLS records ride the same buffered-then-recv path as everything else, and interrupt handling stays in PostgreSQL’s hands. (The BIO_CTRL_EOF quirk in port_bio_ctrl exists because OpenSSL made an undocumented change to how it learns of EOF — the code comment links the upstream issue.)

After a successful SSL_accept, be_tls_open_server pulls the peer certificate (if the client sent one) and extracts both the Common Name and the full RFC 2253 Distinguished Name into the Port, rejecting embedded NULs (the classic CVE-2009-4034 attack, where CN=admin\0.evil.com could fool a naive prefix match):

// be_tls_open_server — src/backend/libpq/be-secure-openssl.c
port->peer = SSL_get_peer_certificate(port->ssl);
if (port->peer != NULL)
{
X509_NAME *x509name = X509_get_subject_name(port->peer);
len = X509_NAME_get_text_by_NID(x509name, NID_commonName, NULL, 0);
...
/* Reject embedded NULLs in certificate common name (CVE-2009-4034). */
if (len != strlen(peer_cn)) { ereport(COMMERROR, ...); pfree(peer_cn); return -1; }
port->peer_cn = peer_cn;
...
X509_NAME_print_ex(bio, x509name, 0, XN_FLAG_RFC2253); /* DN */
...
port->peer_dn = peer_dn;
port->peer_cert_valid = true;
}

Whether a missing or invalid client certificate is fatal is decided later, by pg_hba.conf (clientcert, cert auth method) — the TLS layer merely records what it found. The context-level SSL_CTX_set_verify is set to SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE (in be_tls_init) so the server asks for a cert but does not fail the handshake if one is absent.

GSSAPI is the other secure path. Once secure_open_gssapi has run a Kerberos context establishment, every application byte is wrapped into an encrypted token framed by a 4-byte network-order length:

flowchart LR
    subgraph write["be_gssapi_write"]
      W1["app bytes"] --> W2["gss_wrap<br/>(per ≤PqGSSMaxPktSize chunk)"]
      W2 --> W3["prepend uint32 length<br/>(pg_hton32)"]
      W3 --> W4["secure_raw_write<br/>(loop on partial send)"]
    end
    subgraph read["be_gssapi_read"]
      R1["secure_raw_read 4-byte len"] --> R2["read len bytes<br/>into PqGSSRecvBuffer"]
      R2 --> R3["gss_unwrap<br/>→ PqGSSResultBuffer"]
      R3 --> R4["dole out to caller"]
    end

The framing constant is, by the code’s own admission, part of the protocol spec and can never change — both ends must agree so neither allocates an unbounded buffer:

// be-secure-gssapi.c — packet size caps
#define PQ_GSS_MAX_PACKET_SIZE 16384 /* includes uint32 header word */
#define PQ_GSS_AUTH_BUFFER_SIZE 65536 /* larger, only during auth handshake */

be_gssapi_write loops, slicing the caller’s data into chunks no larger than PqGSSMaxPktSize, wrapping each with gss_wrap, and refusing to claim partial progress (it returns 0-or-all, to keep the retry state machine simple — a hard-won lesson, per the comment citing commit d053a879b):

// be_gssapi_write — src/backend/libpq/be-secure-gssapi.c
major = gss_wrap(&minor, gctx, 1, GSS_C_QOP_DEFAULT, &input, &conf_state, &output);
if (major != GSS_S_COMPLETE) { pg_GSS_error(...); errno = ECONNRESET; return -1; }
if (conf_state == 0) /* refuse if confidentiality not actually applied */
{ ereport(COMMERROR, (errmsg("outgoing GSSAPI message would not use confidentiality"))); errno = ECONNRESET; return -1; }
netlen = pg_hton32(output.length);
memcpy(PqGSSSendBuffer + PqGSSSendLength, &netlen, sizeof(uint32));
PqGSSSendLength += sizeof(uint32);
memcpy(PqGSSSendBuffer + PqGSSSendLength, output.value, output.length);
PqGSSSendLength += output.length;

The conf_state == 0 check is important: gss_wrap is asked for confidentiality (the 1 argument), but the mechanism could in principle honour only integrity. PostgreSQL treats “no confidentiality” as a fatal connection error rather than silently sending integrity-only data.

be_gssapi_read is the mirror: collect a 4-byte length, read exactly that many bytes into PqGSSRecvBuffer, gss_unwrap the whole token into PqGSSResultBuffer, then dole the plaintext out to the caller across as many calls as it takes. The same length cap guards the receive side against an oversize packet from the client:

// be_gssapi_read — src/backend/libpq/be-secure-gssapi.c
input.length = pg_ntoh32(*(uint32 *) PqGSSRecvBuffer);
if (input.length > PQ_GSS_MAX_PACKET_SIZE - sizeof(uint32))
{
ereport(COMMERROR,
(errmsg("oversize GSSAPI packet sent by the client (%zu > %zu)",
(size_t) input.length, PQ_GSS_MAX_PACKET_SIZE - sizeof(uint32))));
errno = ECONNRESET;
return -1;
}
...
major = gss_unwrap(&minor, gctx, &input, &output, &conf_state, NULL);
if (conf_state == 0) /* peer sent integrity-only data: reject */
{ ereport(COMMERROR, (errmsg("incoming GSSAPI message did not use confidentiality"))); errno = ECONNRESET; return -1; }

secure_open_gssapi runs the Kerberos context-establishment loop before any of the above is safe to call. It allocates the larger PQ_GSS_AUTH_BUFFER_SIZE buffers (the auth tokens can reach 64 kB), then loops: the client always speaks first, so it reads a length-framed packet, feeds it to gss_accept_sec_context, and ships back whatever token the GSSAPI library produces — until the library signals the context is complete:

// secure_open_gssapi — src/backend/libpq/be-secure-gssapi.c
major = gss_accept_sec_context(&minor, &port->gss->ctx,
GSS_C_NO_CREDENTIAL, &input,
GSS_C_NO_CHANNEL_BINDINGS,
&port->gss->name, NULL, &output, NULL,
NULL, pg_gss_accept_delegation ? &delegated_creds : NULL);
if (GSS_ERROR(major)) { pg_GSS_error(...); return -1; }
else if (!(major & GSS_S_CONTINUE_NEEDED))
complete_next = true; /* one more send, then done */

When the loop finishes it frees the 64 kB auth buffers and reallocates the 16 kB operational buffers, computes PqGSSMaxPktSize via gss_wrap_size_limit (so be_gssapi_write never produces a token larger than the buffer), and sets port->gss->enc = true — the flag that secure_read/secure_write test on every subsequent call. Optionally, a delegated Kerberos credential is captured here (pg_store_delegated_credential), enabling the backend to act as the client toward other Kerberized services.

Unlike be_gssapi_read/be_gssapi_write, this function blocks on the socket via WaitLatchOrSocket (helper read_or_wait) — establishment is a one-time, ordered exchange where partial-progress bookkeeping would be pointless.

The symbols below are grouped by file and call-flow. Names are the stable anchor; the line numbers in the position-hint table are scoped to the updated: date.

  • secure_initialize / secure_destroy — thin wrappers over be_tls_init / be_tls_destroy; no-ops in a non-SSL build.
  • secure_open_server — called from the negotiation path; stashes any pre-handshake buffered bytes into port->raw_buf (so OpenSSL’s first read is correct), then delegates to be_tls_open_server. Logs the peer DN/CN at DEBUG2.
  • secure_read — the dispatch: be_tls_read if port->ssl_in_use, else be_gssapi_read if port->gss->enc, else secure_raw_read. Owns the blocking-wait loop on FeBeWaitSet and the postmaster-death check.
  • secure_write — the write-side mirror of secure_read.
  • secure_raw_read — drains port->raw_buf first, then recv(2).
  • secure_raw_write — straight send(2). Every cipher’s lowest layer.
  • secure_closebe_tls_close if TLS was in use.
  • be_tls_init — builds the process-wide SSL_CTX: SSLv23_method (negotiate highest TLS), load server cert chain + private key, min/max protocol version, cipher lists, DH/ECDH params, root CA + CRL, and (if a CA is configured) SSL_CTX_set_verify with verify_cb. Reloadable on SIGHUP.
  • be_tls_open_server — per-connection handshake: SSL_new, ssl_set_port_bio, SSL_accept loop, ALPN check (SSL_get0_alpn_selected), peer-certificate identity extraction.
  • be_tls_read / be_tls_writeSSL_read/SSL_write wrapped in the SSL_get_error switch that maps WANT_READ/WANT_WRITE to a *waitfor hint and translates errors to errno.
  • ssl_set_port_bio, port_bio_method, port_bio_read, port_bio_write, port_bio_ctrl — the custom source/sink BIO routing OpenSSL through secure_raw_read/secure_raw_write.
  • verify_cb — certificate-verification callback; on failure builds a detailed cert_errdetail (subject, serial, issuer) for later logging.
  • alpn_cb — ALPN selection; accepts only PG_ALPN_PROTOCOL ("postgresql"), else returns the RFC 7301 no_application_protocol fatal alert.
  • info_cb — copies OpenSSL handshake state strings into the PG log at DEBUG4.
  • be_tls_get_certificate_hash — the channel-binding (RFC 5929 tls-server-end-point) hash of the server certificate, used by SCRAM-SHA-256-PLUS. SHA-256 substituted when the cert signature is MD5/SHA-1.
  • initialize_dh / initialize_ecdh / load_dh_file — ephemeral key parameters for forward secrecy.
  • be_tls_get_version, be_tls_get_cipher, be_tls_get_cipher_bits, be_tls_get_peer_subject_name, be_tls_get_peer_issuer_name — accessors feeding pg_stat_ssl.

be-secure-gssapi.c — GSSAPI transport encryption

Section titled “be-secure-gssapi.c — GSSAPI transport encryption”
  • secure_open_gssapi — Kerberos context establishment (gss_accept_sec_context loop), buffer resize, gss_wrap_size_limit, port->gss->enc = true. Blocks via read_or_wait.
  • be_gssapi_write — chunk → gss_wrap → length-prefix → secure_raw_write; all-or-nothing progress; conf_state confidentiality check.
  • be_gssapi_readsecure_raw_read length + body → gss_unwrap → result buffer → dole out; oversize-packet and conf_state checks.
  • read_or_wait — blocking helper used only during establishment.
  • be_gssapi_get_enc, be_gssapi_get_auth, be_gssapi_get_princ, be_gssapi_get_delegation — accessors.

backend_startup.c — negotiation (cross-ref postgres-wire-protocol.md)

Section titled “backend_startup.c — negotiation (cross-ref postgres-wire-protocol.md)”
  • ProcessStartupPacket — recognises NEGOTIATE_SSL_CODE / NEGOTIATE_GSS_CODE, replies one byte, calls secure_open_server / secure_open_gssapi, sets ssl_done / gss_done, enforces the unencrypted-data-after-request MITM guard.
  • ProcessSSLStartup — direct-TLS path; sniffs the 0x16 record byte.

Position hints (as of 2026-06-05, REL_18 273fe94)

Section titled “Position hints (as of 2026-06-05, REL_18 273fe94)”
SymbolFileLine
secure_initializesrc/backend/libpq/be-secure.c75
secure_open_serversrc/backend/libpq/be-secure.c112
secure_readsrc/backend/libpq/be-secure.c179
secure_raw_readsrc/backend/libpq/be-secure.c268
secure_writesrc/backend/libpq/be-secure.c305
secure_raw_writesrc/backend/libpq/be-secure.c377
be_tls_initsrc/backend/libpq/be-secure-openssl.c97
be_tls_open_serversrc/backend/libpq/be-secure-openssl.c438
be_tls_closesrc/backend/libpq/be-secure-openssl.c734
be_tls_readsrc/backend/libpq/be-secure-openssl.c764
be_tls_writesrc/backend/libpq/be-secure-openssl.c823
port_bio_readsrc/backend/libpq/be-secure-openssl.c911
port_bio_writesrc/backend/libpq/be-secure-openssl.c935
port_bio_ctrlsrc/backend/libpq/be-secure-openssl.c954
ssl_set_port_biosrc/backend/libpq/be-secure-openssl.c1010
verify_cbsrc/backend/libpq/be-secure-openssl.c1205
info_cbsrc/backend/libpq/be-secure-openssl.c1283
alpn_cbsrc/backend/libpq/be-secure-openssl.c1334
be_tls_get_cipher_bitssrc/backend/libpq/be-secure-openssl.c1510
be_tls_get_versionsrc/backend/libpq/be-secure-openssl.c1524
be_tls_get_ciphersrc/backend/libpq/be-secure-openssl.c1533
be_tls_get_certificate_hashsrc/backend/libpq/be-secure-openssl.c1582
X509_NAME_to_cstringsrc/backend/libpq/be-secure-openssl.c1644
PQ_GSS_MAX_PACKET_SIZEsrc/backend/libpq/be-secure-gssapi.c52
PQ_GSS_AUTH_BUFFER_SIZEsrc/backend/libpq/be-secure-gssapi.c60
be_gssapi_writesrc/backend/libpq/be-secure-gssapi.c102
be_gssapi_readsrc/backend/libpq/be-secure-gssapi.c269
read_or_waitsrc/backend/libpq/be-secure-gssapi.c430
secure_open_gssapisrc/backend/libpq/be-secure-gssapi.c502
be_gssapi_get_encsrc/backend/libpq/be-secure-gssapi.c755
CANCEL_REQUEST_CODE / NEGOTIATE_SSL_CODE / NEGOTIATE_GSS_CODEsrc/include/libpq/pqcomm.h137 / 172 / 173
PG_ALPN_PROTOCOL / _VECTORsrc/include/libpq/pqcomm.h165 / 166
ProcessSSLStartupsrc/backend/tcop/backend_startup.c401
ProcessStartupPacket (SSL/GSS branches)src/backend/tcop/backend_startup.c575 / 647

Verified against /data/hgryoo/references/postgres at REL_18 (commit 273fe94, PG 18.x). Cross-checked facts:

  • Dispatch order. secure_read/secure_write test port->ssl_in_use before port->gss->enc; the raw path is the #else fallback. Confirmed in be-secure.c. The two cipher flags are mutually exclusive by construction of the negotiation logic. ✓
  • Negotiation reply bytes. SSLRequest is answered with 'S'/'N', GSSENCRequest with 'G'/'N', each a single secure_write of one byte, sent in cleartext. Confirmed in ProcessStartupPacket. ✓
  • Request codes. NEGOTIATE_SSL_CODE = PG_PROTOCOL(1234,5679), NEGOTIATE_GSS_CODE = PG_PROTOCOL(1234,5680), CANCEL_REQUEST_CODE = PG_PROTOCOL(1234,5678). Confirmed in pqcomm.h. ✓
  • MITM guard. After a handshake, pq_buffer_remaining_data() > 0 triggers a FATAL “received unencrypted data after SSL/GSSAPI … request”. Confirmed in both branches of ProcessStartupPacket. ✓
  • Direct-TLS sniff byte. ProcessSSLStartup returns STATUS_OK (fall-through to normal startup) unless the first byte is 0x16 (TLS handshake ContentType). Confirmed. ✓
  • ALPN protocol string. PG_ALPN_PROTOCOL "postgresql", PG_ALPN_PROTOCOL_VECTOR { 10, 'p','o','s','t','g','r','e','s','q','l' }. alpn_cb returns SSL_TLSEXT_ERR_ALERT_FATAL for any other protocol. Confirmed in pqcomm.h and be-secure-openssl.c. ✓
  • Custom BIO. port_bio_read/port_bio_write call secure_raw_read/secure_raw_write; port_bio_ctrl answers BIO_CTRL_EOF from port->last_read_was_eof. Confirmed. ✓
  • Embedded-NUL rejection. be_tls_open_server rejects a peer CN or DN whose strlen differs from the ASN.1 length (CVE-2009-4034 defence). Confirmed. ✓
  • Client-cert verify mode. be_tls_init sets SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE (ask, don’t require) only when a CA file is configured. Confirmed. ✓
  • Channel-binding hash algorithm. be_tls_get_certificate_hash substitutes SHA-256 when the cert signature NID is NID_md5/NID_sha1, else uses the signature’s own digest (RFC 5929 §4.1). Confirmed. ✓
  • GSS packet caps. PQ_GSS_MAX_PACKET_SIZE 16384 (operational), PQ_GSS_AUTH_BUFFER_SIZE 65536 (handshake). Both be_gssapi_read and be_gssapi_write enforce output.length/input.length ≤ MAX - sizeof(uint32). Confirmed. ✓
  • GSS confidentiality enforcement. Both directions reject conf_state == 0 (integrity-only) as ECONNRESET. Confirmed. ✓
  • GSS delegation. secure_open_gssapi passes &delegated_creds to gss_accept_sec_context only when pg_gss_accept_delegation is set, and stores via pg_store_delegated_credential. Confirmed. ✓
  • TLS min default. ssl_min_protocol_version defaults to PG_TLS1_2_VERSION; ssl_max_protocol_version to PG_TLS_ANY. Confirmed in be-secure.c globals (enum in libpq.h). ✓

Out of scope for this revision (deferred to cross-referenced docs): the pg_hba.conf matching that decides whether a missing/invalid client cert is fatal (postgres-authentication.md, planned); the SCRAM-SHA-256 exchange that consumes the channel-binding hash (same); the startup-packet parsing mechanics around the negotiation branches (postgres-wire-protocol.md); and contrib/ modules (out of scope by directive — named only as examples).

Beyond PostgreSQL — Comparative Designs & Research Frontiers

Section titled “Beyond PostgreSQL — Comparative Designs & Research Frontiers”

Pointers, not analysis. Each bullet is a starting handle for a follow-up document.

  • NSS / GnuTLS / Schannel alternatives. PostgreSQL’s backend currently builds against OpenSSL (and LibreSSL via the OpenSSL-compatible API). The ssl_library GUC reports which is in use, but the build supports one TLS backend at a time. Other databases (e.g. MySQL’s historical YaSSL, MariaDB’s optional GnuTLS) have shipped pluggable TLS backends. The be-secure-openssl.cbe-secure.c boundary is exactly the seam where a second backend (be-secure-nss.c) was prototyped upstream and then abandoned; the abstract be_tls_* interface is the artifact of that effort. A comparison of why the NSS port stalled (callback-model mismatch, FIPS plumbing) would illuminate how leaky the “abstract TLS” boundary really is.

  • Direct/implicit TLS adoption (the ALPN gate). PG17 added the sslnegotiation=direct client mode and the 0x16-sniffing server path, but requires the postgresql ALPN protocol on direct connections to avoid being a generic TLS reflector. HTTPS-style implicit TLS removes one round trip; the trade-off is that the server can no longer multiplex cleartext and TLS clients on the port without the ALPN discriminator. A follow-up could measure the latency win of direct TLS against connection poolers (PgBouncer) that terminate TLS themselves.

  • Channel binding and the MITM threat model. SCRAM-SHA-256-PLUS mixes be_tls_get_certificate_hash into the auth proof so a TLS-terminating proxy cannot relay credentials. RFC 5929 tls-server-end-point binds to the certificate; the newer tls-exporter (RFC 9266) binds to the TLS session key material and is mandatory for TLS 1.3 in some profiles. PostgreSQL uses end-point binding; whether/when it should move to tls-exporter is an open upstream discussion. Pairs with the planned postgres-authentication.md.

  • GSSAPI delegation and the confused-deputy risk. secure_open_gssapi can accept delegated Kerberos credentials (pg_gss_accept_delegation), letting the backend impersonate the client to downstream Kerberized services (e.g. postgres_fdw to a remote PG, or a Kerberized HDFS). This is powerful and dangerous — a classic confused-deputy surface. The design rationale (off by default, per-pg_hba opt-in) and the blast radius deserve a deeper look alongside postgres-fdw.md.

  • Encryption cost vs. the buffer-coalescing layer. GSSAPI framing caps packets at 16 kB and re-buffers; TLS records have their own MTU-ish size. Both interact with pqcomm.c’s 8 kB send buffer (postgres-wire-protocol.md). Whether the GSS 16 kB packet size or the TLS record size is the right coalescing granularity for a COPY-heavy bulk-load workload is an empirical question; the comment in be-secure-gssapi.c admits the buffer sizes were chosen to “minimize the times where we have to make multiple packets,” but offers no measurement.

  • Post-quantum key exchange. OpenSSL 3.x is gaining hybrid PQ key exchange (e.g. X25519MLKEM768). Because PostgreSQL delegates the entire key-exchange to OpenSSL via SSLv23_method and the cipher/group GUCs (ssl_groups/SSLECDHCurve), enabling a PQ group is largely an OpenSSL configuration concern rather than a PostgreSQL code change — a clean example of where the thin-shim design pays off.

PostgreSQL source (under /data/hgryoo/references/postgres, REL_18 273fe94)

Section titled “PostgreSQL source (under /data/hgryoo/references/postgres, REL_18 273fe94)”
  • src/backend/libpq/be-secure.csecure_initialize, secure_open_server, secure_read, secure_raw_read, secure_write, secure_raw_write, secure_close; the cipher-agnostic dispatch shim and recv/send floor.
  • src/backend/libpq/be-secure-openssl.cbe_tls_init, be_tls_open_server, be_tls_read/be_tls_write, the custom BIO (port_bio_read/port_bio_write/port_bio_ctrl/ssl_set_port_bio), verify_cb, alpn_cb, info_cb, be_tls_get_certificate_hash, and the be_tls_get_* accessors.
  • src/backend/libpq/be-secure-gssapi.csecure_open_gssapi, be_gssapi_read/be_gssapi_write, read_or_wait, the PQ_GSS_* caps, and the be_gssapi_get_* accessors.
  • src/backend/tcop/backend_startup.cProcessStartupPacket (SSL/GSS negotiation branches), ProcessSSLStartup (direct TLS).
  • src/include/libpq/pqcomm.hNEGOTIATE_SSL_CODE, NEGOTIATE_GSS_CODE, CANCEL_REQUEST_CODE, PG_ALPN_PROTOCOL, PG_ALPN_PROTOCOL_VECTOR.
  • src/include/libpq/libpq.hPG_TLS_ANY, PG_TLS1_2_VERSION protocol version enum.
  • RFC 5929 — Channel Bindings for TLS (tls-server-end-point), basis for be_tls_get_certificate_hash.
  • RFC 7301 — Application-Layer Protocol Negotiation (ALPN); the no_application_protocol fatal alert in alpn_cb.
  • RFC 2744 / GSS-API — gss_accept_sec_context, gss_wrap/gss_unwrap semantics referenced by secure_open_gssapi and the GSS I/O functions.
  • RFC 2253 — String representation of X.509 Distinguished Names; the format X509_NAME_print_ex(..., XN_FLAG_RFC2253) produces for port->peer_dn.

Textbook chapters (under knowledge/research/dbms-general/)

Section titled “Textbook chapters (under knowledge/research/dbms-general/)”
  • Database System Concepts (Silberschatz et al.) — application-level security: encryption in flight vs. authentication vs. authorization.
  • Database Internals (Petrov) — node-to-node communication and the thin cryptographic shim beneath the message codec.
  • postgres-wire-protocol.md — the FE/BE framing that rides on top of this layer; full startup-packet mechanics around the negotiation branches; pqcomm.c send/receive buffers that secure_raw_read/secure_raw_write feed.
  • postgres-authentication.md — (planned) auth.c, pg_hba.conf matching, the cert auth method that consumes port->peer_cn, and the SCRAM-SHA-256-PLUS exchange that consumes the channel-binding hash.
  • postgres-backend-lifecycle.md — how ProcessStartupPacket is reached from postmaster → backend startup, before PostgresMain.
  • postgres-fdw.md — consumer of GSSAPI credential delegation captured in secure_open_gssapi.