PostgreSQL Transport Security — TLS (OpenSSL) and GSSAPI Encryption
Contents:
- Theoretical Background
- Common DBMS Design
- PostgreSQL’s Approach
- Source Walkthrough
- Source verification (as of 2026-06-05)
- Beyond PostgreSQL — Comparative Designs & Research Frontiers
- Sources
Theoretical Background
Section titled “Theoretical Background”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:
-
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 callsread/writeon an abstract channel and never learns whether a cipher sits underneath. -
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.
-
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.
Common DBMS Design
Section titled “Common DBMS Design”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.
A dispatch shim beneath the message codec
Section titled “A dispatch shim beneath the message codec”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.
Certificate-based mutual authentication
Section titled “Certificate-based mutual authentication”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 ↔ PostgreSQL mapping
Section titled “Theory ↔ PostgreSQL mapping”| Theory / convention | PostgreSQL name |
|---|---|
| Dispatch shim beneath the codec | secure_read / secure_write in be-secure.c |
| Raw-socket primitive | secure_raw_read / secure_raw_write (recv/send) |
| In-band TLS negotiation request | NEGOTIATE_SSL_CODE (SSLRequest), one-byte 'S'/'N' reply |
| In-band GSS negotiation request | NEGOTIATE_GSS_CODE (GSSENCRequest), one-byte 'G'/'N' reply |
| Direct/implicit TLS | ProcessSSLStartup sniffs 0x16 TLS record byte; ALPN postgresql |
| TLS handshake | be_tls_open_server → SSL_accept |
| Custom socket BIO | port_bio_read / port_bio_write / ssl_set_port_bio |
| TLS record I/O | be_tls_read (SSL_read) / be_tls_write (SSL_write) |
| Server cert / key load | be_tls_init (SSL_CTX_use_certificate_chain_file, …) |
| Client cert verify callback | verify_cb, set via SSL_CTX_set_verify |
| Peer identity extraction | port->peer_cn, port->peer_dn in be_tls_open_server |
| Channel-binding hash | be_tls_get_certificate_hash (RFC 5929) |
| GSSAPI transport encryption | be_gssapi_read / be_gssapi_write (gss_unwrap/gss_wrap) |
| GSSAPI session establishment | secure_open_gssapi (gss_accept_sec_context) |
| GSS packet cap | PQ_GSS_MAX_PACKET_SIZE (16 kB), PQ_GSS_AUTH_BUFFER_SIZE (64 kB) |
PostgreSQL’s Approach
Section titled “PostgreSQL’s Approach”PostgreSQL splits transport security across three source files with a clean responsibility boundary:
-
be-secure.c— the cipher-agnostic shim. It ownssecure_read/secure_write(the dispatch),secure_raw_read/secure_raw_write(therecv/sendprimitives every cipher ultimately calls), and the thinsecure_open_server/secure_closewrappers. 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.cnever 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 feedpg_stat_ssland thessl_*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.cretry:#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.cif (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.cif (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;Negotiation: SSLRequest / GSSENCRequest
Section titled “Negotiation: SSLRequest / GSSENCRequest”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.cif (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.cif (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.cfirstbyte = 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.
TLS handshake structure
Section titled “TLS handshake structure”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.cSSL_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.cstatic intport_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.)
Client certificates: identity extraction
Section titled “Client certificates: identity extraction”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.cport->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 transport encryption
Section titled “GSSAPI transport encryption”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.cmajor = 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.cinput.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; }GSSAPI session establishment
Section titled “GSSAPI session establishment”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.cmajor = 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.
Source Walkthrough
Section titled “Source Walkthrough”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.
be-secure.c — the cipher-agnostic shim
Section titled “be-secure.c — the cipher-agnostic shim”secure_initialize/secure_destroy— thin wrappers overbe_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 intoport->raw_buf(so OpenSSL’s first read is correct), then delegates tobe_tls_open_server. Logs the peer DN/CN atDEBUG2.secure_read— the dispatch:be_tls_readifport->ssl_in_use, elsebe_gssapi_readifport->gss->enc, elsesecure_raw_read. Owns the blocking-wait loop onFeBeWaitSetand the postmaster-death check.secure_write— the write-side mirror ofsecure_read.secure_raw_read— drainsport->raw_buffirst, thenrecv(2).secure_raw_write— straightsend(2). Every cipher’s lowest layer.secure_close—be_tls_closeif TLS was in use.
be-secure-openssl.c — TLS via OpenSSL
Section titled “be-secure-openssl.c — TLS via OpenSSL”be_tls_init— builds the process-wideSSL_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_verifywithverify_cb. Reloadable on SIGHUP.be_tls_open_server— per-connection handshake:SSL_new,ssl_set_port_bio,SSL_acceptloop, ALPN check (SSL_get0_alpn_selected), peer-certificate identity extraction.be_tls_read/be_tls_write—SSL_read/SSL_writewrapped in theSSL_get_errorswitch that mapsWANT_READ/WANT_WRITEto a*waitforhint and translates errors toerrno.ssl_set_port_bio,port_bio_method,port_bio_read,port_bio_write,port_bio_ctrl— the custom source/sink BIO routing OpenSSL throughsecure_raw_read/secure_raw_write.verify_cb— certificate-verification callback; on failure builds a detailedcert_errdetail(subject, serial, issuer) for later logging.alpn_cb— ALPN selection; accepts onlyPG_ALPN_PROTOCOL("postgresql"), else returns the RFC 7301no_application_protocolfatal alert.info_cb— copies OpenSSL handshake state strings into the PG log atDEBUG4.be_tls_get_certificate_hash— the channel-binding (RFC 5929tls-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 feedingpg_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_contextloop), buffer resize,gss_wrap_size_limit,port->gss->enc = true. Blocks viaread_or_wait.be_gssapi_write— chunk →gss_wrap→ length-prefix →secure_raw_write; all-or-nothing progress;conf_stateconfidentiality check.be_gssapi_read—secure_raw_readlength + body →gss_unwrap→ result buffer → dole out; oversize-packet andconf_statechecks.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— recognisesNEGOTIATE_SSL_CODE/NEGOTIATE_GSS_CODE, replies one byte, callssecure_open_server/secure_open_gssapi, setsssl_done/gss_done, enforces the unencrypted-data-after-request MITM guard.ProcessSSLStartup— direct-TLS path; sniffs the0x16record byte.
Position hints (as of 2026-06-05, REL_18 273fe94)
Section titled “Position hints (as of 2026-06-05, REL_18 273fe94)”| Symbol | File | Line |
|---|---|---|
secure_initialize | src/backend/libpq/be-secure.c | 75 |
secure_open_server | src/backend/libpq/be-secure.c | 112 |
secure_read | src/backend/libpq/be-secure.c | 179 |
secure_raw_read | src/backend/libpq/be-secure.c | 268 |
secure_write | src/backend/libpq/be-secure.c | 305 |
secure_raw_write | src/backend/libpq/be-secure.c | 377 |
be_tls_init | src/backend/libpq/be-secure-openssl.c | 97 |
be_tls_open_server | src/backend/libpq/be-secure-openssl.c | 438 |
be_tls_close | src/backend/libpq/be-secure-openssl.c | 734 |
be_tls_read | src/backend/libpq/be-secure-openssl.c | 764 |
be_tls_write | src/backend/libpq/be-secure-openssl.c | 823 |
port_bio_read | src/backend/libpq/be-secure-openssl.c | 911 |
port_bio_write | src/backend/libpq/be-secure-openssl.c | 935 |
port_bio_ctrl | src/backend/libpq/be-secure-openssl.c | 954 |
ssl_set_port_bio | src/backend/libpq/be-secure-openssl.c | 1010 |
verify_cb | src/backend/libpq/be-secure-openssl.c | 1205 |
info_cb | src/backend/libpq/be-secure-openssl.c | 1283 |
alpn_cb | src/backend/libpq/be-secure-openssl.c | 1334 |
be_tls_get_cipher_bits | src/backend/libpq/be-secure-openssl.c | 1510 |
be_tls_get_version | src/backend/libpq/be-secure-openssl.c | 1524 |
be_tls_get_cipher | src/backend/libpq/be-secure-openssl.c | 1533 |
be_tls_get_certificate_hash | src/backend/libpq/be-secure-openssl.c | 1582 |
X509_NAME_to_cstring | src/backend/libpq/be-secure-openssl.c | 1644 |
PQ_GSS_MAX_PACKET_SIZE | src/backend/libpq/be-secure-gssapi.c | 52 |
PQ_GSS_AUTH_BUFFER_SIZE | src/backend/libpq/be-secure-gssapi.c | 60 |
be_gssapi_write | src/backend/libpq/be-secure-gssapi.c | 102 |
be_gssapi_read | src/backend/libpq/be-secure-gssapi.c | 269 |
read_or_wait | src/backend/libpq/be-secure-gssapi.c | 430 |
secure_open_gssapi | src/backend/libpq/be-secure-gssapi.c | 502 |
be_gssapi_get_enc | src/backend/libpq/be-secure-gssapi.c | 755 |
CANCEL_REQUEST_CODE / NEGOTIATE_SSL_CODE / NEGOTIATE_GSS_CODE | src/include/libpq/pqcomm.h | 137 / 172 / 173 |
PG_ALPN_PROTOCOL / _VECTOR | src/include/libpq/pqcomm.h | 165 / 166 |
ProcessSSLStartup | src/backend/tcop/backend_startup.c | 401 |
ProcessStartupPacket (SSL/GSS branches) | src/backend/tcop/backend_startup.c | 575 / 647 |
Source verification (as of 2026-06-05)
Section titled “Source verification (as of 2026-06-05)”Verified against /data/hgryoo/references/postgres at REL_18 (commit
273fe94, PG 18.x). Cross-checked facts:
- Dispatch order.
secure_read/secure_writetestport->ssl_in_usebeforeport->gss->enc; the raw path is the#elsefallback. Confirmed inbe-secure.c. The two cipher flags are mutually exclusive by construction of the negotiation logic. ✓ - Negotiation reply bytes.
SSLRequestis answered with'S'/'N',GSSENCRequestwith'G'/'N', each a singlesecure_writeof one byte, sent in cleartext. Confirmed inProcessStartupPacket. ✓ - Request codes.
NEGOTIATE_SSL_CODE = PG_PROTOCOL(1234,5679),NEGOTIATE_GSS_CODE = PG_PROTOCOL(1234,5680),CANCEL_REQUEST_CODE = PG_PROTOCOL(1234,5678). Confirmed inpqcomm.h. ✓ - MITM guard. After a handshake,
pq_buffer_remaining_data() > 0triggers a FATAL “received unencrypted data after SSL/GSSAPI … request”. Confirmed in both branches ofProcessStartupPacket. ✓ - Direct-TLS sniff byte.
ProcessSSLStartupreturnsSTATUS_OK(fall-through to normal startup) unless the first byte is0x16(TLS handshakeContentType). Confirmed. ✓ - ALPN protocol string.
PG_ALPN_PROTOCOL "postgresql",PG_ALPN_PROTOCOL_VECTOR { 10, 'p','o','s','t','g','r','e','s','q','l' }.alpn_cbreturnsSSL_TLSEXT_ERR_ALERT_FATALfor any other protocol. Confirmed inpqcomm.handbe-secure-openssl.c. ✓ - Custom BIO.
port_bio_read/port_bio_writecallsecure_raw_read/secure_raw_write;port_bio_ctrlanswersBIO_CTRL_EOFfromport->last_read_was_eof. Confirmed. ✓ - Embedded-NUL rejection.
be_tls_open_serverrejects a peer CN or DN whosestrlendiffers from the ASN.1 length (CVE-2009-4034 defence). Confirmed. ✓ - Client-cert verify mode.
be_tls_initsetsSSL_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_hashsubstitutes SHA-256 when the cert signature NID isNID_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). Bothbe_gssapi_readandbe_gssapi_writeenforceoutput.length/input.length ≤ MAX - sizeof(uint32). Confirmed. ✓ - GSS confidentiality enforcement. Both directions reject
conf_state == 0(integrity-only) asECONNRESET. Confirmed. ✓ - GSS delegation.
secure_open_gssapipasses&delegated_credstogss_accept_sec_contextonly whenpg_gss_accept_delegationis set, and stores viapg_store_delegated_credential. Confirmed. ✓ - TLS min default.
ssl_min_protocol_versiondefaults toPG_TLS1_2_VERSION;ssl_max_protocol_versiontoPG_TLS_ANY. Confirmed inbe-secure.cglobals (enum inlibpq.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_libraryGUC 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. Thebe-secure-openssl.c⇄be-secure.cboundary is exactly the seam where a second backend (be-secure-nss.c) was prototyped upstream and then abandoned; the abstractbe_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=directclient mode and the0x16-sniffing server path, but requires thepostgresqlALPN 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_hashinto the auth proof so a TLS-terminating proxy cannot relay credentials. RFC 5929tls-server-end-pointbinds to the certificate; the newertls-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 totls-exporteris an open upstream discussion. Pairs with the plannedpostgres-authentication.md. -
GSSAPI delegation and the confused-deputy risk.
secure_open_gssapican accept delegated Kerberos credentials (pg_gss_accept_delegation), letting the backend impersonate the client to downstream Kerberized services (e.g.postgres_fdwto 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_hbaopt-in) and the blast radius deserve a deeper look alongsidepostgres-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 aCOPY-heavy bulk-load workload is an empirical question; the comment inbe-secure-gssapi.cadmits 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 viaSSLv23_methodand 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.
Sources
Section titled “Sources”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.c—secure_initialize,secure_open_server,secure_read,secure_raw_read,secure_write,secure_raw_write,secure_close; the cipher-agnostic dispatch shim andrecv/sendfloor.src/backend/libpq/be-secure-openssl.c—be_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 thebe_tls_get_*accessors.src/backend/libpq/be-secure-gssapi.c—secure_open_gssapi,be_gssapi_read/be_gssapi_write,read_or_wait, thePQ_GSS_*caps, and thebe_gssapi_get_*accessors.src/backend/tcop/backend_startup.c—ProcessStartupPacket(SSL/GSS negotiation branches),ProcessSSLStartup(direct TLS).src/include/libpq/pqcomm.h—NEGOTIATE_SSL_CODE,NEGOTIATE_GSS_CODE,CANCEL_REQUEST_CODE,PG_ALPN_PROTOCOL,PG_ALPN_PROTOCOL_VECTOR.src/include/libpq/libpq.h—PG_TLS_ANY,PG_TLS1_2_VERSIONprotocol version enum.
Standards
Section titled “Standards”- RFC 5929 — Channel Bindings for TLS (
tls-server-end-point), basis forbe_tls_get_certificate_hash. - RFC 7301 — Application-Layer Protocol Negotiation (ALPN); the
no_application_protocolfatal alert inalpn_cb. - RFC 2744 / GSS-API —
gss_accept_sec_context,gss_wrap/gss_unwrapsemantics referenced bysecure_open_gssapiand the GSS I/O functions. - RFC 2253 — String representation of X.509 Distinguished Names; the format
X509_NAME_print_ex(..., XN_FLAG_RFC2253)produces forport->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.
Cross-references (sibling module docs)
Section titled “Cross-references (sibling module docs)”postgres-wire-protocol.md— the FE/BE framing that rides on top of this layer; full startup-packet mechanics around the negotiation branches;pqcomm.csend/receive buffers thatsecure_raw_read/secure_raw_writefeed.postgres-authentication.md— (planned)auth.c,pg_hba.confmatching, thecertauth method that consumesport->peer_cn, and the SCRAM-SHA-256-PLUS exchange that consumes the channel-binding hash.postgres-backend-lifecycle.md— howProcessStartupPacketis reached frompostmaster→ backend startup, beforePostgresMain.postgres-fdw.md— consumer of GSSAPI credential delegation captured insecure_open_gssapi.