From 2f55559492a7dc3946db59c5958ce349ad1fa985 Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Fri, 3 Jul 2026 02:47:50 +0530 Subject: [PATCH 1/5] Add SCRAM-SHA-256-PLUS support Signed-off-by: Snehil Shah --- lib/postgrex.ex | 6 ++++ lib/postgrex/protocol.ex | 29 ++++++++++++--- lib/postgrex/scram.ex | 78 ++++++++++++++++++++++++++++++++++++---- test/login_test.exs | 35 ++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/lib/postgrex.ex b/lib/postgrex.ex index 1f2f68af..7017518a 100644 --- a/lib/postgrex.ex +++ b/lib/postgrex.ex @@ -124,6 +124,12 @@ defmodule Postgrex do - true: enable SSL with secure defaults, including peer certificate verification and hostname checking. - keyword list of `t::ssl.tls_client_option()/0` values: enable SSL and merge your options on top of secure defaults. + * `:channel_binding` - Controls the use of SCRAM channel binding (`SCRAM-SHA-256-PLUS`). + Only applies to SSL connections against servers using SCRAM authentication. Set to: + - `:prefer` (default): use channel binding when the server supports it. + - `:require`: refuse to connect unless channel binding is used. + - `:disable`: never use channel binding. + * `:socket_options` - Options to be given to the underlying socket (applies to both TCP and UNIX sockets); diff --git a/lib/postgrex/protocol.ex b/lib/postgrex/protocol.ex index 236bf5f6..f923b7e5 100644 --- a/lib/postgrex/protocol.ex +++ b/lib/postgrex/protocol.ex @@ -32,6 +32,7 @@ defmodule Postgrex.Protocol do buffer: nil, disconnect_on_error_codes: [], scram: nil, + scram_cb: nil, disable_composite_types: false, messages: [] @@ -120,6 +121,16 @@ defmodule Postgrex.Protocol do :unnamed -> :unnamed end + channel_binding = + case opts[:channel_binding] || :prefer do + binding when binding in [:prefer, :require, :disable] -> + binding + + other -> + raise ArgumentError, + "expected :channel_binding to be :prefer, :require or :disable, got: #{inspect(other)}" + end + parameters = case opts[:search_path] do path when is_list(path) -> @@ -158,6 +169,7 @@ defmodule Postgrex.Protocol do prepare: prepare, messages: [], ssl: ssl_opts, + channel_binding: channel_binding, target_server_type: target_server_type } @@ -900,8 +912,8 @@ defmodule Postgrex.Protocol do {:ok, msg_auth(type: :md5, data: salt), buffer} -> auth_md5(s, status, salt, buffer) - {:ok, msg_auth(type: :sasl, data: _), buffer} -> - auth_sasl(s, status, buffer) + {:ok, msg_auth(type: :sasl, data: mechanisms), buffer} -> + auth_sasl(s, status, mechanisms, buffer) {:ok, msg_auth(type: :sasl_cont, data: data), buffer} -> auth_cont(s, status, data, buffer) @@ -931,12 +943,19 @@ defmodule Postgrex.Protocol do auth_send(s, msg_password(pass: ["md5", digest, 0]), status, buffer) end - defp auth_sasl(s, status = _, buffer) do - auth_send(s, msg_password(pass: Postgrex.SCRAM.client_first()), status, buffer) + defp auth_sasl(s, %{channel_binding: channel_binding} = status, mechanisms, buffer) do + case Postgrex.SCRAM.negotiate_channel_binding(mechanisms, s.sock, channel_binding) do + {:ok, cb} -> + s = %{s | scram_cb: cb} + auth_send(s, msg_password(pass: Postgrex.SCRAM.client_first(cb)), status, buffer) + + {:error, error} -> + disconnect(s, error, buffer) + end end defp auth_cont(s, %{opts: opts} = status, data, buffer) do - {client_final_msg, scram_state} = Postgrex.SCRAM.client_final(data, opts) + {client_final_msg, scram_state} = Postgrex.SCRAM.client_final(data, s.scram_cb, opts) s = %{s | scram: scram_state} auth_send(s, msg_password(pass: client_final_msg), status, buffer) end diff --git a/lib/postgrex/scram.ex b/lib/postgrex/scram.ex index c59f0ed9..8ac3a732 100644 --- a/lib/postgrex/scram.ex +++ b/lib/postgrex/scram.ex @@ -6,15 +6,56 @@ defmodule Postgrex.SCRAM do @hash_length 32 @nonce_length 24 @nonce_rand_bytes div(@nonce_length * 6, 8) - @nonce_prefix "n,,n=,r=" - @nonce_encoded_size <> + @cb_gs2_header "p=tls-server-end-point,," + + # Chooses the SCRAM mechanism and gs2 header from the mechanisms the server offered. + def negotiate_channel_binding(server_mechanisms, sock, mode) do + offered = parse_mechanisms(server_mechanisms) + plus_offered? = "SCRAM-SHA-256-PLUS" in offered + tls? = match?({:ssl, _}, sock) + + cond do + mode == :disable -> + {:ok, {"SCRAM-SHA-256", "n,,", ""}} + + tls? and plus_offered? -> + case cert_hash(sock) do + {:ok, hash} -> + {:ok, {"SCRAM-SHA-256-PLUS", @cb_gs2_header, hash}} + + :error when mode == :require -> + {:error, + %Postgrex.Error{ + message: + "channel binding is required but the server certificate has no usable hash" + }} + + # Fallback + :error -> + {:ok, {"SCRAM-SHA-256", "y,,", ""}} + end + + mode == :require -> + {:error, + %Postgrex.Error{ + message: + "channel binding is required but not offered by the server, or the connection is not using SSL" + }} + + true -> + cbind_flag = if tls?, do: "y,,", else: "n,," + {:ok, {"SCRAM-SHA-256", cbind_flag, ""}} + end + end - def client_first do + def client_first({mechanism, gs2_header, _cbind_data}) do nonce = @nonce_rand_bytes |> :crypto.strong_rand_bytes() |> Base.encode64() - ["SCRAM-SHA-256", 0, @nonce_encoded_size, @nonce_prefix, nonce] + sasl_data = [gs2_header, "n=,r=", nonce] + size = <> + [mechanism, 0, size, sasl_data] end - def client_final(data, opts) do + def client_final(data, {_mechanism, gs2_header, cbind_data}, opts) do # Extract data from server-first message server = parse_server_data(data) {:ok, server_s} = Base.decode64(server[?s]) @@ -30,7 +71,8 @@ defmodule Postgrex.SCRAM do end) # Construct client signature and proof - message_without_proof = ["c=biws,r=", server[?r]] + cbind_input = Base.encode64(IO.iodata_to_binary([gs2_header, cbind_data])) + message_without_proof = ["c=", cbind_input, ",r=", server[?r]] client_nonce = binary_part(server[?r], 0, @nonce_length) message = ["n=,r=", client_nonce, ",r=", server[?r], ",s=", server[?s], ",i=", server[?i], ?,] auth_message = IO.iodata_to_binary([message | message_without_proof]) @@ -50,6 +92,30 @@ defmodule Postgrex.SCRAM do |> do_verify_server(scram_state, opts) end + defp parse_mechanisms(data) do + data |> :binary.split(<<0>>, [:global]) |> Enum.reject(&(&1 == "")) + end + + # RFC 5929 tls-server-end-point + defp cert_hash({:ssl, sslsock}) do + with {:ok, der} <- :ssl.peercert(sslsock), + {:Certificate, _tbs, sig_alg, _sig} <- :public_key.pkix_decode_cert(der, :plain), + {_tag, oid, _params} <- sig_alg, + {digest, _sign} when digest != :none <- pkix_sign_type(oid) do + # Ref: https://www.rfc-editor.org/info/rfc5929/#section-4.1 + digest = if digest in [:md5, :sha], do: :sha256, else: digest + {:ok, :crypto.hash(digest, der)} + else + _ -> :error + end + end + + defp pkix_sign_type(oid) do + :public_key.pkix_sign_types(oid) + rescue + _ -> {:none, :unknown} + end + defp do_verify_server(%{?e => server_e}, _scram_state, _opts) do msg = "error received in SCRAM server final message: #{inspect(server_e)}" {:error, %Postgrex.Error{message: msg}} diff --git a/test/login_test.exs b/test/login_test.exs index bf34a03e..b0dc208b 100644 --- a/test/login_test.exs +++ b/test/login_test.exs @@ -88,6 +88,41 @@ defmodule LoginTest do assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) end + @tag :ssl + @tag min_pg_version: "11.0" + test "login scram password with channel binding required", context do + opts = [ + username: "postgrex_scram_pw", + password: "postgrex_scram_pw", + ssl: [verify: :verify_none], + channel_binding: :require + ] + + assert {:ok, pid} = P.start_link(opts ++ context[:options]) + assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) + end + + @tag :ssl + @tag min_pg_version: "11.0" + test "login scram password with channel binding disabled", context do + opts = [ + username: "postgrex_scram_pw", + password: "postgrex_scram_pw", + ssl: [verify: :verify_none], + channel_binding: :disable + ] + + assert {:ok, pid} = P.start_link(opts ++ context[:options]) + assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) + end + + test "invalid channel_binding option", context do + assert capture_log(fn -> + opts = [channel_binding: :invalid, show_sensitive_data_on_connection_error: true] + assert_start_and_killed(opts ++ context[:options]) + end) =~ "expected :channel_binding to be :prefer, :require or :disable" + end + test "env var defaults", context do assert {:ok, pid} = P.start_link(context[:options]) assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) From 6fb5a0ed992618a3e2ff82cf764382baa58507c4 Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Fri, 3 Jul 2026 03:23:59 +0530 Subject: [PATCH 2/5] Gate connection validation test for Elixir v1.17+ Signed-off-by: Snehil Shah --- test/login_test.exs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/test/login_test.exs b/test/login_test.exs index b0dc208b..da3df6ef 100644 --- a/test/login_test.exs +++ b/test/login_test.exs @@ -116,11 +116,21 @@ defmodule LoginTest do assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) end - test "invalid channel_binding option", context do - assert capture_log(fn -> - opts = [channel_binding: :invalid, show_sensitive_data_on_connection_error: true] - assert_start_and_killed(opts ++ context[:options]) - end) =~ "expected :channel_binding to be :prefer, :require or :disable" + # gen_statem reports (raised in connect/2) are only captured on Elixir v1.17+, + # and a bug crashes the Logger on v1.17.0/v1.17.1. + if Version.match?(System.version(), ">= 1.17.2") do + test "invalid channel_binding option", context do + Process.flag(:trap_exit, true) + opts = [channel_binding: :invalid, show_sensitive_data_on_connection_error: true] + + error_log = + capture_log(fn -> + Postgrex.start_link(opts ++ context[:options]) + assert_receive {:EXIT, _, :killed} + end) + + assert error_log =~ "expected :channel_binding to be :prefer, :require or :disable" + end end test "env var defaults", context do From 12dacdf59eff8436c6b80127b7714ee74bbb4199 Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Sat, 4 Jul 2026 01:14:20 +0530 Subject: [PATCH 3/5] Add channel_binding to start_option type Signed-off-by: Snehil Shah --- lib/postgrex.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/postgrex.ex b/lib/postgrex.ex index 7017518a..416d21ae 100644 --- a/lib/postgrex.ex +++ b/lib/postgrex.ex @@ -48,6 +48,7 @@ defmodule Postgrex do | {:handshake_timeout, timeout} | {:ping_timeout, timeout} | {:ssl, boolean | [:ssl.tls_client_option()]} + | {:channel_binding, :prefer | :require | :disable} | {:socket_options, [:gen_tcp.connect_option()]} | {:prepare, :named | :unnamed} | {:transactions, :strict | :naive} From 6774ba9e0167a90fa37f82c3c4e1e3975966fcad Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Sat, 4 Jul 2026 01:50:55 +0530 Subject: [PATCH 4/5] Add test for channel binding enforced Signed-off-by: Snehil Shah --- test/login_test.exs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/login_test.exs b/test/login_test.exs index da3df6ef..72011d02 100644 --- a/test/login_test.exs +++ b/test/login_test.exs @@ -102,6 +102,33 @@ defmodule LoginTest do assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) end + @tag min_pg_version: "10.0" + test "login scram password with channel binding required without SSL", context do + assert capture_log(fn -> + opts = [ + username: "postgrex_scram_pw", + password: "postgrex_scram_pw", + channel_binding: :require + ] + + assert_start_and_killed(opts ++ context[:options]) + end) =~ "channel binding is required" + end + + @tag :ssl + @tag min_pg_version: "11.0" + test "login scram password with channel binding preferred", context do + opts = [ + username: "postgrex_scram_pw", + password: "postgrex_scram_pw", + ssl: [verify: :verify_none], + channel_binding: :prefer + ] + + assert {:ok, pid} = P.start_link(opts ++ context[:options]) + assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", []) + end + @tag :ssl @tag min_pg_version: "11.0" test "login scram password with channel binding disabled", context do From 292a37fdd56483ea6274d83b4fb1c2577af57abe Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Sat, 4 Jul 2026 19:01:23 +0530 Subject: [PATCH 5/5] Fix incorrect fallback header when `-PLUS` offered Signed-off-by: Snehil Shah --- lib/postgrex/scram.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/postgrex/scram.ex b/lib/postgrex/scram.ex index 8ac3a732..5aaabf68 100644 --- a/lib/postgrex/scram.ex +++ b/lib/postgrex/scram.ex @@ -32,7 +32,7 @@ defmodule Postgrex.SCRAM do # Fallback :error -> - {:ok, {"SCRAM-SHA-256", "y,,", ""}} + {:ok, {"SCRAM-SHA-256", "n,,", ""}} end mode == :require ->