SCRAM-SHA-256-PLUS (Channel Binding): Design Plan #182
SeanTAllen
started this conversation in
Research
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Design: discussion #72, item #25
Overview
SCRAM-SHA-256-PLUS ties SCRAM authentication to the TLS session by embedding the server's certificate hash into the SCRAM proof. This prevents credential relay attacks where an attacker terminates TLS and forwards the SCRAM exchange to the real server over a different TLS connection.
Change type: Added (new feature). PR label:
changelog - added.Scope
In scope: SCRAM-SHA-256-PLUS with
tls-server-end-pointchannel binding. GS2y,,downgrade detection. User-configurable channel binding policy.Out of scope:
tls-unique(deprecated in TLS 1.3, not supported by PostgreSQL),tls-exporter(TLS 1.3+ only, not supported by PostgreSQL as of v17), SASLprep normalization (same assumption as existing SCRAM).Protocol Changes
SCRAM-SHA-256-PLUS differs from SCRAM-SHA-256 in three ways:
SCRAM-SHA-256-PLUSinstead ofSCRAM-SHA-256p=tls-server-end-point,,instead ofn,,c=field:base64("p=tls-server-end-point,," + cert_hash_bytes)instead ofbiws(which isbase64("n,,"))The GS2 flag has three states:
n,,— client does not support channel binding (plaintext connection, or channel binding disabled)y,,— client supports channel binding but is not using it (TLS active, server doesn't offer PLUS, or cert extraction failed). Enables server-side downgrade detection per RFC 5802 Section 6.p=tls-server-end-point,,— client is using channel bindingThe crypto (PBKDF2, HMAC, proof computation) is identical. Only the AuthMessage changes because the
c=field contains different data, producing a different proof.Channel Binding Data Computation (RFC 5929 Section 4.1)
The
tls-server-end-pointchannel binding value is the hash of the server's DER-encoded certificate. The hash algorithm is determined by the certificate's signature algorithm:This matches libpq's
pgtls_get_peer_certificate_hash()implementation, which usesX509_get_signature_nid+OBJ_find_sigid_algsto determine the hash, thenX509_digestto compute it.Mechanism Selection
Given the client's TLS state, channel binding policy, and server's mechanism list:
SCRAM-SHA-256withn,,SCRAM-SHA-256withn,,ChannelBindingRequiredSCRAM-SHA-256-PLUSwithp=tls-server-end-point,,SCRAM-SHA-256withy,,SCRAM-SHA-256-PLUSwithp=tls-server-end-point,,ChannelBindingRequiredSCRAM-SHA-256withn,,SCRAM-SHA-256withy,,ChannelBindingRequiredSCRAM-SHA-256withn,,UnsupportedAuthenticationMethod(no supported mechanism)"CB data available" can be false even on TLS if certificate extraction fails (e.g., server sends no certificate, which shouldn't happen with PostgreSQL but is defensively handled).
Three-Phase Dependency Chain
Phase 1: ponylang/ssl — Add channel binding data extraction
New method on
SSLinssl/net/ssl.pony:Returns the
tls-server-end-pointchannel binding value (hash of server cert DER), orNoneif no peer certificate is available.Implementation:
SSL_get_peer_certificate/SSL_get1_peer_certificate(already FFI-declared, platform-branched) to get the server certX509_get_signature_nid(cert)to get the signature algorithm NIDOBJ_find_sigid_algs(sig_nid, &hash_nid, NULL)to extract the hash algorithm NIDNID_md5 = 4) or SHA-1 (NID_sha1 = 64), replace with SHA-256 (NID_sha256 = 672) per RFC 5929EVP_get_digestbynid(hash_nid)to get the digest methodX509_digest(cert, md, buf, &len)to compute the hashX509_free(already declared)Array[U8] val, truncated to actual lengthNew FFI declarations:
Design rationale: Encapsulating the full RFC 5929 computation (cert extraction + algorithm selection + hashing) at the SSL level keeps all OpenSSL FFI in one package and prevents consumers from needing to know about hash algorithm selection. This is safer than exposing raw
peer_certificate_der()which would push the algorithm-selection responsibility to each consumer.Testing: Unit test with a client-server TLS pair using the existing test cert/key infrastructure. Verify the method returns a non-None value of the expected length (32 bytes for SHA-256-signed certs). Verify against a manually computed expected hash.
Phase 2: ponylang/lori — Expose channel binding from TCPConnection
New method on
TCPConnectioninlori/tcp_connection.pony:Returns
Nonefor plaintext connections. Delegates toSSLfor TLS connections. Only meaningful after TLS handshake completes — before then,SSL_get_peer_certificatereturns null, so the method naturally returnsNone.Testing: Verify
Nonefor plaintext connections, non-None for post-handshake TLS connections.Phase 3: ponylang/postgres — Implement SCRAM-SHA-256-PLUS
Step 3a: New public types
postgres/channel_binding.pony— new file:Modified
ServerConnectInfo— addchannel_bindingfield defaulting toChannelBindingPrefer:New error variant in
AuthenticationFailureReason:Step 3b: Parameterize
_ScramSha256Replace hardcoded
"n,,"and"biws"with parameters. Add methods:gs2_header(channel_binding_data: (Array[U8] val | None), server_offers_plus: Bool, channel_binding: ChannelBinding): String— returns the GS2 header string based on the decision matrixchannel_binding_response(gs2_header: String, channel_binding_data: (Array[U8] val | None)): String— returns the base64-encodedc=value:base64(gs2_header + channel_binding_bytes)for PLUS, orbase64(gs2_header)for non-PLUSclient_first_message(nonce, gs2_header)to accept the GS2 headerclient_final_message_without_proof(combined_nonce, channel_binding_response)to accept the channel binding responsecompute_proof(...)to accept the channel binding response and use it in the AuthMessageThe existing non-PLUS behavior is preserved when
gs2_header = "n,,"andchannel_binding_response = "biws".Step 3c: Thread
ChannelBindingand_tls_activethrough the state machineThread the
ChannelBindingpreference through existing states the same waycodec_registryis threaded:_SessionUnopened→_SessionSSLNegotiating/_SessionConnected→_AuthenticableState→_SessionSCRAMAuthenticatingAdd
_channel_binding: ChannelBindingfield to_SessionUnopened,_SessionSSLNegotiating, and_SessionConnected. The_ConnectableStatetrait gets achannel_binding()accessor.Add
_tls_active: Boolfield to_SessionConnectedto track whether TLS actually completed. This is needed becausecb_databeingNoneon a TLS connection (cert extraction failed) is different fromcb_databeingNoneon a plaintext connection — the former requiresy,,GS2 header for downgrade detection, the latter usesn,,. Set as follows:_ConnectableState.on_connected()withSSLDisabled:_tls_active = false_SessionSSLNegotiating.on_tls_ready()→_proceed_to_connected(s, true):_tls_active = true_SessionSSLNegotiatingplaintext fallback (SSLPreferred, server refused) →_proceed_to_connected(s, false):_tls_active = false_proceed_to_connectedgains atls_active: Boolparameter to distinguish the two call paths. Currently both paths call the same method with identical arguments — the new parameter lets_SessionConnectedknow whether TLS actually completed.The
_AuthenticableStatetrait gets atls_active()accessor.Step 3d: Extract channel binding data on-demand
In
_AuthenticableState.on_authentication_sasl(), extract channel binding data by callings._connection().tls_server_end_point_channel_binding(). This works because:s._connection()is already accessible in this method (used at line 3050)If the call returns
Noneon a TLS connection (cert extraction failed), treat it as "TLS active but no CB data" — usey,,for Prefer, fail for Require.Step 3e: Mechanism selection in
on_authentication_sasl()Replace the current simple
"SCRAM-SHA-256"mechanism check with the full decision matrix from the table above.Call sites that need parameterization:
_FrontendMessage.sasl_initial_response("SCRAM-SHA-256", response)at session.pony line 3050-3051 — mechanism string must be"SCRAM-SHA-256-PLUS"or"SCRAM-SHA-256"based on selection_ScramSha256.client_first_message(nonce)— must accept the GS2 header parameter_SessionSCRAMAuthenticatingconstructor (session.pony line 654) — must accept the channel binding response stringNote:
client_first_message_bare(n=,r=<nonce>) does NOT change between PLUS and non-PLUS — only the GS2 header prefix that wraps it changes. The bare part is alwaysn=,r=<nonce>per RFC 5802.The selection logic:
Note on
SSLPreferred+ChannelBindingRequire: if the server refuses SSL and the connection falls back to plaintext,tls_activeisfalseandcb_dataisNone, so SASL auth will correctly fail withChannelBindingRequired. This is expected behavior — the user asked for both "try SSL" and "require channel binding", and the latter can't be satisfied without TLS.Step 3f: Update
_SessionSCRAMAuthenticatingAdd constructor parameter and field:
_channel_binding_response: String— precomputed base64 value for thec=field, passed fromon_authentication_sasl()at construction timeCurrent constructor (line 654) takes
client_nonce,client_first_bare,password,codec_registry. Addchannel_binding_responseparameter.In
on_authentication_sasl_continue(), pass_channel_binding_responsetocompute_proof()andclient_final_message_without_proof()instead of hardcoded"biws".Step 3g:
_CancelSender— no changes neededCancel protocol sends a
CancelRequestmessage, not SASL authentication. The cancel connection may use TLS but never authenticates.Testing
Unit tests (no PostgreSQL needed)
GS2 header generation (
_test_scram.pony):n,,when no CB data (plaintext)y,,when TLS active + CB data available + server doesn't offer PLUSy,,when TLS active + CB data unavailable (cert extraction failed)p=tls-server-end-point,,when TLS active + CB data available + server offers PLUSn,,when TLS active +ChannelBindingDisableChannel binding response:
biws= base64("n,,") for no bindingeSws= base64("y,,") for downgrade detection"p=tls-server-end-point,," + cert_hash_bytesfor PLUSProof computation with channel binding:
biwsMechanism selection decision matrix:
y,,cases, break assertion to verify downgrade detection is actually testedChannel binding data computation (property-based):
channel_binding_response("p=tls-server-end-point,,", data)base64-decodes to"p=tls-server-end-point,," + dataAll new test classes use
\nodoc\on declarations per project convention.Mock server protocol tests (ports 7724-7730, next available above current max 7723)
All mock server tests must explicitly close/dispose TCP listeners and connections to avoid CI timeouts (Pony runtime won't exit while actors with live I/O resources exist).
_TestSCRAMPLUSSuccess(port 7724): Mock SCRAM-PLUS handshake with injected CB data. Mock server offers both mechanisms, verifies client selects PLUS and sends correct GS2 header + channel binding response._TestSCRAMPLUSDowngradeYFlag(port 7725): Mock server offers onlySCRAM-SHA-256(not PLUS). Plaintext connection. Verify client usesn,,(no TLS, so no downgrade flag)._TestSCRAMPLUSServerVerificationFailed(port 7726): PLUS handshake where mock sends wrong server signature. Verifypg_session_authentication_failedwithServerVerificationFailed._TestSCRAMPLUSOnlyNoCBData(port 7727): Mock offers only PLUS, client has no CB data. Verifypg_session_authentication_failedwithUnsupportedAuthenticationMethod._TestSCRAMPLUSRequireFails(port 7728): Mock offers only base SCRAM, client has CB data +ChannelBindingRequire. VerifyChannelBindingRequired._TestSCRAMPLUSTLSFullStack(port 7729): TLS mock server (using test cert/key) + SCRAM-PLUS exchange. Validates the full pipeline: TLS handshake → cert extraction → hash computation → SCRAM-PLUS proof. This is the single most important test — it exercises the entire dependency chain.Integration tests (against real PostgreSQL containers)
_TestSSLSCRAMPLUSAuthenticate: Connect to SSL container (port 5433) withSSLRequired+ChannelBindingPrefer. Verify authentication succeeds. PostgreSQL 14.5 automatically offers PLUS over SSL withscram-sha-256auth._TestSSLSCRAMPLUSQuery: Connect with channel binding, execute query, verify results._TestSSLSCRAMPLUSRequired: Connect withChannelBindingRequireover SSL. Verify success._TestSCRAMPLUSRequireNoSSL: Connect to plain container (port 5432) withChannelBindingRequire. VerifyChannelBindingRequiredfailure._TestSCRAMPLUSDisableOverSSL: Connect to SSL container withChannelBindingDisable. Verify authentication still succeeds (uses plain SCRAM-SHA-256).Build and run:
CI infrastructure
No container changes needed. PostgreSQL 14.5 with
scram-sha-256auth automatically offersSCRAM-SHA-256-PLUSon SSL connections. Empirical verification needed (Phase 0 below).Documentation updates
CLAUDE.md: AddChannelBindingto public API types, addChannelBindingRequiredtoAuthenticationFailureReasonunion, update authentication section to include SCRAM-SHA-256-PLUS, update state machine to document channel binding data flowpostgres.pony: Add channel binding to supported featuresssl-queryexample to showChannelBindingRequireusageexamples/README.mdImplementation Phases
Phase 0: Empirical verification (before any code)
on_authentication_sasl()to confirm PostgreSQL 14.5 advertisesSCRAM-SHA-256-PLUSover SSLSSL_get_peer_certificatereturns non-null cert even withset_client_verify(false)(existing test containers don't verify client certs)Phase 1: ponylang/ssl PR
tls_server_end_point_channel_binding()toSSLX509_get_signature_nid,OBJ_find_sigid_algs,EVP_get_digestbynid,X509_digest)Phase 2: ponylang/lori PR
tls_server_end_point_channel_binding()toTCPConnectionPhase 3: ponylang/postgres PR
corral.jsondependencies,make cleanChannelBindingtypes,ChannelBindingRequirederror variantchannel_bindingtoServerConnectInfo_ScramSha256, implement mechanism selectionChannelBindingand_tls_activethrough state machineDesign Decisions
Decision 1: Channel binding type —
tls-server-end-pointonlyChoice: Support only
tls-server-end-point.Alternatives:
tls-unique(deprecated in TLS 1.3),tls-exporter(TLS 1.3+ only, not supported by PostgreSQL).Confidence: High. PostgreSQL exclusively uses
tls-server-end-pointfor SCRAM-SHA-256-PLUS.Decision 2: Hash algorithm — RFC 5929 compliant, not hardcoded SHA-256
Choice: Use the certificate's signature hash algorithm with SHA-256 as floor (MD5/SHA-1 → SHA-256).
Alternatives: Always SHA-256 (simpler, covers 99%+ of certs).
Confidence: High. This matches libpq's
pgtls_get_peer_certificate_hash()and is the RFC-correct behavior. The complexity is encapsulated in the ssl package.Decision 3: SSL package API — specific
tls_server_end_point_channel_binding(), not generalpeer_certificate_der()Choice: Return the computed hash, not raw DER bytes.
Alternatives:
peer_certificate_der()— more general, lets caller choose hash.Confidence: High. Encapsulates RFC 5929 algorithm selection. Prevents consumers from using the wrong hash. A general cert API can be added later if needed.
Decision 4: Public API —
ChannelBindingtype onServerConnectInfoChoice: Three primitives (
ChannelBindingPrefer/Require/Disable) onServerConnectInfo, defaulting toChannelBindingPrefer.Alternatives: (a) No public API, always automatic. (b) On
SSLModevariants.Confidence: High.
Requireis essential for security-conscious deployments.Disableis essential for pgbouncer/proxy users. These map to libpq'schannel_bindingparameter. Orthogonal toSSLMode(transport vs auth concern). Default ofPreferis secure-by-default without breaking existing code.Decision 5: Channel binding data extraction — on-demand in
on_authentication_sasl()Choice: Call
s._connection().tls_server_end_point_channel_binding()at SASL auth time.Alternatives: Extract in
_SessionSSLNegotiating.on_tls_ready()and thread through intermediate states.Confidence: High. Simpler — avoids adding fields to intermediate states. The connection is accessible and TLS handshake is complete by auth time.
Decision 6: Parameterize
_ScramSha256rather than duplicateChoice: Add parameters to existing methods for GS2 header and channel binding data.
Alternatives: Separate
_ScramSha256Plusprimitive.Confidence: High. The crypto is identical between PLUS and non-PLUS. Only the GS2 header and
c=value differ.Decision 7:
_CancelSender— no changesChoice: No modifications to
_CancelSender.Confidence: High. Cancel protocol uses
CancelRequest, not SASL authentication.Decision 8: Duplicate
@SHA256FFI in ssl/net rather than cross-package importChoice: If
@SHA256is needed directly in ssl/net, declare it there rather than importing ssl/crypto.Alternatives:
use "../crypto"cross-package import.Confidence: High. No existing file in the ssl library uses cross-package imports. With the
X509_digestapproach, this may not even be needed (the digest is computed by OpenSSL internally).Open Questions
Upstream API shape: Is
SSL.tls_server_end_point_channel_binding()the right method name and return type for ponylang/ssl?LibreSSL FFI compatibility:
X509_get_signature_nid,OBJ_find_sigid_algs,EVP_get_digestbynid,X509_digestneed verification across OpenSSL 1.1.x, 3.0.x, and LibreSSL. LibreSSL sometimes diverges on less common APIs.AuthenticationFailureReasonexpansion: AddingChannelBindingRequiredto this union type is technically a breaking change for exhaustive matches in user code. Is this acceptable, or should it be a separate error path?Phase 0 empirical verification: We assume PostgreSQL 14.5 advertises
SCRAM-SHA-256-PLUSon SSL connections and thatSSL_get_peer_certificateworks withset_client_verify(false). Both are highly likely but need verification.i2d_X509FFI pointer semantics in Pony: If theX509_digestapproach requires intermediate steps, thei2d_X509two-call pattern (first for length, second to write) requiresaddressofon a localPointer[U8]variable. This FFI pattern needs empirical verification. However,X509_digestmay avoid this entirely by computing the hash internally.Beta Was this translation helpful? Give feedback.
All reactions