SCRAM-SHA-256 Authentication: Design Plan #83
Closed
SeanTAllen
started this conversation in
Research
Replies: 2 comments
-
|
decision 1: we want to do the work in ponylang/ssl |
Beta Was this translation helpful? Give feedback.
0 replies
-
|
The ponylang/ssl prerequisite has been implemented and released in ponylang/ssl 1.0.3 |
Beta Was this translation helpful? Give feedback.
0 replies
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.
-
SCRAM-SHA-256 Authentication: Design Plan
This document covers the design for adding SCRAM-SHA-256 authentication support to the postgres driver -- the second Tier 1 item in the roadmap. SCRAM-SHA-256 has been the default authentication method since PostgreSQL 10, and MD5 (currently the only method we support) is deprecated.
Change type: Added (new feature). PR label:
changelog - added.Scope
In scope: SCRAM-SHA-256 authentication (mechanism name
SCRAM-SHA-256), without channel binding (GS2 flagn). MD5 authentication continues to work alongside SCRAM -- the server determines which method to use viapg_hba.conf, and the driver handles whichever auth request type the server sends (type 5 for MD5, type 10 for SASL/SCRAM).Out of scope:
SCRAM-SHA-256-PLUS(channel binding) -- follow-up workProtocol Flow
SCRAM-SHA-256 replaces the single-round-trip MD5 exchange with a multi-step SASL handshake:
The crypto operations:
The client sends the ClientProof; the server returns the ServerSignature. The client verifies the ServerSignature to authenticate the server (mutual authentication).
Wire Format
AuthenticationSASL (type 10): Backend message. Same
'R'byte as other auth messages. After the Int32(10) type code: null-terminated mechanism name strings, terminated by an empty string (single null byte).SASLInitialResponse (frontend, type
'p'):String(mechanism) Int32(response_length) Bytes(client-first-message). The mechanism name is null-terminated. The response length is the byte length of the client-first-message. A length of -1 means no initial response.AuthenticationSASLContinue (type 11): Backend message. After Int32(11): raw bytes of server-first-message to end of message (not null-terminated).
SASLResponse (frontend, type
'p'): Raw bytes of client-final-message. Not null-terminated.AuthenticationSASLFinal (type 12): Backend message. After Int32(12): raw bytes of server-final-message to end of message.
SCRAM Message Content
n,,n=,r=<client_nonce>-- GS2 headern,,(no channel binding, no authzid), empty username (following libpq convention; PostgreSQL ignores the SCRAM username and uses the StartupMessage username)r=<combined_nonce>,s=<base64_salt>,i=<iterations>-- the combined nonce is the client nonce with the server's nonce appendedc=biws,r=<combined_nonce>,p=<base64_proof>--biwsis base64("n,,")v=<base64_server_signature>(success) ore=<error>(failure)The AuthMessage used for HMAC computation is:
client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof, where "bare" means without the GS2 header (n=,r=<nonce>), and "without-proof" means without the,p=...suffix (c=biws,r=<combined_nonce>).Implementation Approach
Crypto Primitives
The implementation requires HMAC-SHA-256, PBKDF2, and secure random byte generation, none of which are currently available in
ponylang/ssl. SHA-256 (Digest.sha256()) and constant-time comparison (ConstantTimeCompare) are already available. Base64 encoding is in Pony stdlib. See Decision 1 for where the missing primitives should be implemented.State Machine
Add a new
_SessionSCRAMAuthenticatingstate to handle the multi-step SASL exchange. This follows the precedent of_SessionSSLNegotiating-- a separate state class for a multi-step protocol exchange with intermediate data.On receiving AuthenticationSASL(10),
_SessionConnected(via_AuthenticableState.on_authentication_sasl):SCRAM-SHA-256is in the mechanism list (if not, see Decision 3)RAND_bytes, which naturally avoids the forbidden,character)_SessionSCRAMAuthenticatingcarrying: client nonce, client-first-message-bare, user credentials, password, notify reference, and the readbuf_SessionSCRAMAuthenticatinghandles these callbacks:on_authentication_sasl_continue(msg)-- parses server-first-message, validates the combined nonce starts with the client nonce, computes SaltedPassword/ClientProof/ServerSignature, sends SASLResponse, stores expected ServerSignatureon_authentication_sasl_final(msg)-- verifies server signature usingConstantTimeCompareon raw decoded bytes (not base64 strings); on mismatch, terminates the connection (see Decision 2)on_authentication_ok()-- transitions to_SessionLoggedInon_authentication_failed()-- notifies client and shuts down (same behavior as_AuthenticableState; handles ErrorResponse with codes 28000/28P01 during the SCRAM exchange, which_ResponseMessageParseralready intercepts and routes to this callback)The state mixes in
_ConnectedState(for write capability and readbuf handling) and implements auth callbacks directly -- it cannot use_AuthenticableStatebecause it needs different callback combinations (noon_authentication_md5_password, but has SASL-specific callbacks).on_authentication_md5_passwordandon_authentication_sasldefault to_IllegalState().Trait hierarchy changes for the new callbacks: Three new methods are added to
_SessionState:on_authentication_sasl,on_authentication_sasl_continue,on_authentication_sasl_final. Default_IllegalState()implementations for all three go into_NotAuthenticableState, which covers all states except_SessionConnectedand_SessionSCRAMAuthenticating._AuthenticableState(used by_SessionConnected) provides the realon_authentication_saslimplementation and_IllegalState()foron_authentication_sasl_continue/on_authentication_sasl_final(which should never arrive while still in_SessionConnected)._SessionSCRAMAuthenticatingprovides real implementations foron_authentication_sasl_continueandon_authentication_sasl_finaland_IllegalState()foron_authentication_saslandon_authentication_md5_password.Parser Changes
_authentication_request_type.pony: Addsasl(): I32 => 10,sasl_continue(): I32 => 11,sasl_final(): I32 => 12._backend_messages.pony: Three new message classes:_AuthenticationSASLMessage-- carries mechanism list asArray[String] val_AuthenticationSASLContinueMessage-- carries raw server-first-message asArray[U8] val_AuthenticationSASLFinalMessage-- carries raw server-final-message asArray[U8] valAdd these to
_AuthenticationMessagesunion type._response_parser.pony: Extend the auth type dispatch in theauthentication_request()branch to handle types 10, 11, 12. For type 10, parse null-terminated strings until empty string. For 11 and 12, extract remaining bytes as raw data._response_message_parser.pony: Route the three new message types to session state callbacks.Frontend Messages
_frontend_message.pony: Two new message builders:sasl_initial_response(mechanism: String, response: Array[U8] val): Array[U8] val-- type'p', body is null-terminated mechanism name + Int32(response length) + response bytessasl_response(response: Array[U8] val): Array[U8] val-- type'p', body is raw response bytesTesting
Crypto unit tests: HMAC-SHA-256 against RFC 4231 test vectors. PBKDF2 against RFC 6070 test vectors.
SCRAM logic unit tests: Full SCRAM computation against the RFC 5802 test vector (username "user", password "pencil", known nonce/salt/iterations -> expected proof and server signature).
Protocol unit tests (mock server): Following the
_TestSSLNegotiation*pattern:pg_session_authenticatedfirespg_session_authentication_failedfiresFrontend message unit tests: Verify wire format of SASLInitialResponse and SASLResponse.
Parser unit tests: Verify parsing of AuthenticationSASL(10), AuthenticationSASLContinue(11), AuthenticationSASLFinal(12).
Integration tests: Connect to PostgreSQL configured with SCRAM-SHA-256: successful auth, failed auth (wrong password), query execution after SCRAM auth, SSL + SCRAM combined.
Build and run:
make ssl=3.0.x(all tests),make unit-tests ssl=3.0.x(unit only).Example
No new example is needed. SCRAM-SHA-256 is negotiated transparently -- the server determines the auth method, not the client. Existing examples work unchanged against a SCRAM-configured server.
Design Decisions
Decision 1: Where to implement HMAC-SHA-256, PBKDF2, and RAND_bytes
These are general-purpose cryptographic primitives that don't exist in
ponylang/ssltoday. All three have stable OpenSSL C APIs (HMAC(),PKCS5_PBKDF2_HMAC(),RAND_bytes()).Option A -- Add to
ponylang/ssl: Add FFI wrappers in thessl/cryptopackage. Architecturally clean -- crypto primitives belong in the crypto package, andponylang/sslalready has the OpenSSL FFI infrastructure. This would be a prerequisite: design, implement, test, review, releaseponylang/ssl, then update the dependency in postgres.Option B -- Implement in
postgresvia direct OpenSSL FFI: Adduse @HMAC[...]anduse @PKCS5_PBKDF2_HMAC[...]declarations directly in the postgres package. Faster to ship but puts crypto code in the wrong package and duplicates FFI infrastructure.Decision 2: How to report server signature verification failure
If the server's signature in AuthenticationSASLFinal doesn't match our computed ServerSignature, the connection must be terminated. This indicates a potential MITM attack. How should this be surfaced to the user?
Option A -- New
AuthenticationFailureReasonvariant: AddServerVerificationFailedtoAuthenticationFailureReason. Firepg_session_authentication_failedwith this reason. The application can distinguish "wrong password" (InvalidPassword) from "server couldn't verify itself" (ServerVerificationFailed).Option B -- Use
pg_session_connection_failed: Treat it as a connection-level failure rather than an authentication failure, since the issue is the server's identity, not the client's credentials.Option C -- Reuse
InvalidAuthenticationSpecification: Simple, but lumps together very different failure modes.Decision 3: Unsupported mechanism handling
If the server sends AuthenticationSASL(10) but the mechanism list doesn't include
SCRAM-SHA-256, how should we handle it? The current behavior for truly unsupported auth types (e.g., cleartext) is that_ResponseParserreturns_UnsupportedMessage, which is silently consumed by_ResponseMessageParser, and the session hangs until timeout.Option A -- Fire
pg_session_authentication_failedwithInvalidAuthenticationSpecification: Explicit failure. Consistent with how we handle auth errors.Option B -- Fire
pg_session_connection_failed: Treats it as a connection-level incompatibility. Consistent with how SSL refusal is handled.Note: either option is an improvement over the current silent hang for unsupported auth types. Whichever we pick, we could also consider retroactively improving the
_UnsupportedMessagehandling.Decision 4: Integration test infrastructure
The current CI uses PostgreSQL containers with MD5 auth (user: postgres, password: postgres). We need SCRAM-configured PostgreSQL for integration tests.
Option A -- Add SCRAM user to existing containers: Configure
pg_hba.confto usescram-sha-256for a specific user while keepingmd5for the existing user. Least infrastructure change.Option B -- Add new container pair (plain + SSL) with SCRAM auth: Separate containers on separate ports. Clean separation but more CI resources.
Option C -- Switch to SCRAM default, keep one MD5 user: Make SCRAM the default (matching modern PostgreSQL defaults), add a dedicated MD5 user for backward-compat tests.
Phasing
ponylang/ssl. Separate repo, separate PR cycle.Beta Was this translation helpful? Give feedback.
All reactions