Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/postgrex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -124,6 +125,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`).
Comment thread
Snehil-Shah marked this conversation as resolved.
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);

Expand Down
29 changes: 24 additions & 5 deletions lib/postgrex/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Postgrex.Protocol do
buffer: nil,
disconnect_on_error_codes: [],
scram: nil,
scram_cb: nil,
disable_composite_types: false,
messages: []

Expand Down Expand Up @@ -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) ->
Expand Down Expand Up @@ -158,6 +169,7 @@ defmodule Postgrex.Protocol do
prepare: prepare,
messages: [],
ssl: ssl_opts,
channel_binding: channel_binding,
target_server_type: target_server_type
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 72 additions & 6 deletions lib/postgrex/scram.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<byte_size(@nonce_prefix) + @nonce_length::signed-size(32)>>
@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
Comment thread
josevalim marked this conversation as resolved.
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", "n,,", ""}}
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 = <<IO.iodata_length(sasl_data)::signed-size(32)>>
[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])
Expand All @@ -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])
Expand All @@ -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}}
Expand Down
72 changes: 72 additions & 0 deletions test/login_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,78 @@ 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 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
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

# 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How easy it is to add a test for channel_binding: :require over non-SSL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easy and obvious, I'll add it right away!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josevalim Added that test and also added a test for :prefer. Although, the :disable and :prefer tests don't really test the actual negotiation logic. It's just a sanity test. Can be removed, or we can work on finding a way to test the actual negotiation logic.. WDYT?


test "env var defaults", context do
assert {:ok, pid} = P.start_link(context[:options])
assert {:ok, %Postgrex.Result{}} = P.query(pid, "SELECT 123", [])
Expand Down
Loading