Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ defmodule Supavisor.ClientAuthentication.ValidationSecrets do
defp sasl_secrets_from_password(user, password) do
iterations = 4096
salt = :crypto.strong_rand_bytes(16)
salted_password = :pgo_scram.hi(:pgo_sasl_prep_profile.validate([password]), salt, iterations)
salted_password = :pgo_scram.hi(PgSASLprep.scram_normalize(password), salt, iterations)
client_key = :pgo_scram.hmac(salted_password, "Client Key")
stored_key = :pgo_scram.h(client_key)
server_key = :pgo_scram.hmac(salted_password, "Server Key")
Expand Down
5 changes: 2 additions & 3 deletions lib/supavisor/client_handler/auth_methods.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ defmodule Supavisor.ClientHandler.AuthMethods do
{:ok, :jit | :password | :scram_sha_256} | {:error, SslRequiredError.t()}
def fetch_authentication_method(tenant, client_jit, ssl?, user) do
case {tenant.use_jit, client_jit, ssl?} do
{false, _, _} -> {:ok, :scram_sha_256}
{true, false, false} -> {:ok, :scram_sha_256}
{true, false, true} -> {:ok, :password}
{_, false, false} -> {:ok, :scram_sha_256}
{_, false, true} -> {:ok, :password}
{true, true, false} -> {:error, %SslRequiredError{user: user}}
{true, true, true} -> {:ok, :jit}
end
Expand Down
2 changes: 1 addition & 1 deletion lib/supavisor/client_handler/auth_methods/password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ defmodule Supavisor.ClientHandler.AuthMethods.Password do
else
salted_password =
:pgo_scram.hi(
:pgo_sasl_prep_profile.validate([password]),
PgSASLprep.scram_normalize(password),
sasl_secrets.salt,
sasl_secrets.iterations
)
Expand Down
10 changes: 8 additions & 2 deletions lib/supavisor/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ defmodule Supavisor.Helpers do
salt = srv_first.salt
i = srv_first.i

salted_password = :pgo_scram.hi(:pgo_sasl_prep_profile.validate(secrets.password), salt, i)
salted_password =
:pgo_scram.hi(PgSASLprep.scram_normalize(IO.iodata_to_binary(secrets.password)), salt, i)

client_key = :pgo_scram.hmac(salted_password, "Client Key")
stored_key = :pgo_scram.h(client_key)
client_first_bare = [<<"n=">>, user_name, <<",r=">>, client_nonce]
Expand Down Expand Up @@ -291,7 +293,11 @@ defmodule Supavisor.Helpers do
%Supavisor.Secrets.SASLSecrets{} = secrets
) do
salted_password =
:pgo_scram.hi(:pgo_sasl_prep_profile.validate(password), secrets.salt, secrets.iterations)
:pgo_scram.hi(
PgSASLprep.scram_normalize(IO.iodata_to_binary(password)),
secrets.salt,
secrets.iterations
)

client_key = :pgo_scram.hmac(salted_password, "Client Key")
stored_key = :pgo_scram.h(client_key)
Expand Down
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ defmodule Supavisor.MixProject do
{:poolboy, git: "https://github.com/supabase/poolboy", tag: "v0.0.3"},
{:syn, "~> 3.3"},
{:pgo, "~> 0.13"},
{:pg_saslprep,
github: "v0idpwn/pg_saslprep", ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"},
{:rustler, "~> 0.36.1"},
{:ranch, "~> 2.0", override: true},

Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"open_api_spex": {:hex, :open_api_spex, "3.21.2", "6a704f3777761feeb5657340250d6d7332c545755116ca98f33d4b875777e1e5", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "f42ae6ed668b895ebba3e02773cfb4b41050df26f803f2ef634c72a7687dc387"},
"opentelemetry_api": {:hex, :opentelemetry_api, "1.4.0", "63ca1742f92f00059298f478048dfb826f4b20d49534493d6919a0db39b6db04", [:mix, :rebar3], [], "hexpm", "3dfbbfaa2c2ed3121c5c483162836c4f9027def469c41578af5ef32589fcfc58"},
"peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"},
"pg_saslprep": {:git, "https://github.com/v0idpwn/pg_saslprep.git", "455e2e50757e8e5e091c00be4c1b9818612e3ca2", [ref: "455e2e50757e8e5e091c00be4c1b9818612e3ca2"]},
"pg_types": {:hex, :pg_types, "0.4.0", "3ce365c92903c5bb59c0d56382d842c8c610c1b6f165e20c4b652c96fa7e9c14", [:rebar3], [], "hexpm", "b02efa785caececf9702c681c80a9ca12a39f9161a846ce17b01fb20aeeed7eb"},
"pgo": {:hex, :pgo, "0.14.0", "f53711d103d7565db6fc6061fcf4ff1007ab39892439be1bb02d9f686d7e6663", [:rebar3], [{:backoff, "~> 1.1.6", [hex: :backoff, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:pg_types, "~> 0.4.0", [hex: :pg_types, repo: "hexpm", optional: false]}], "hexpm", "71016c22599936e042dc0012ee4589d24c71427d266292f775ebf201d97df9c9"},
"phoenix": {:hex, :phoenix, "1.7.23", "2a86f055b50f3ca2e692f8bc0e757b7bde6a44182476ec9193e337ccb7cf5492", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "98b551a267cbcd0ca4a2bfe05ff2fb3cd68699197a2a3e14504f6b7be758ca9d"},
Expand Down
142 changes: 138 additions & 4 deletions test/integration/protocol_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,148 @@
# end
end

defp recv_until_ready_for_query(sock, buf) do
{pkts, ""} = Supavisor.Protocol.split_pkts(buf)
describe "fullwidth password (SASLprep private-use bug workaround)" do
# `:pgo_sasl_prep_profile.validate/1` has a buggy private-use range that
# incorrectly rejects characters in the BMP (e.g. fullwidth `!` U+FF01).
@password String.duplicate("!", 8)
@role "fullwidth_pw_user"
@tenant "is_manager"

setup do
Supavisor.Support.SSLHelper.setup_downstream_certs()

Check notice

Code scanning / Credo

Nested modules could be aliased at the top of the invoking module. Note test

Nested modules could be aliased at the top of the invoking module.

db_conf = Application.get_env(:supavisor, Supavisor.Repo)

{:ok, origin} = connect_origin(db_conf)
Postgrex.query!(origin, "DROP ROLE IF EXISTS #{@role};", [])

Postgrex.query!(
origin,
"CREATE ROLE #{@role} WITH LOGIN PASSWORD '#{@password}';",
[]
)

on_exit(fn ->
{:ok, cleanup} = connect_origin(db_conf)
Postgrex.query!(cleanup, "DROP ROLE IF EXISTS #{@role};", [])
end)

%{
db_conf: db_conf,
port: Application.get_env(:supavisor, :proxy_port_transaction),
username: "#{@role}.#{@tenant}"
}
end

test "authenticates over plain TCP (SCRAM)", ctx do
{:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", ctx.port, [:binary, active: false])

send_startup(:gen_tcp, sock, ctx)
auth_tail = do_scram_exchange(:gen_tcp, sock, ctx.username, @password)
recv_until_ready_for_query(:gen_tcp, sock, auth_tail)

:ok = :gen_tcp.send(sock, :pgo_protocol.encode_query_message("SELECT 1"))
{:ok, data} = :gen_tcp.recv(sock, 0, 5000)
assert_data_row(data)
:gen_tcp.close(sock)
end

test "authenticates over SSL (cleartext password)", ctx do
{:ok, tcp} = :gen_tcp.connect(~c"127.0.0.1", ctx.port, [:binary, active: false])
:ok = :gen_tcp.send(tcp, Server.ssl_request_message())
{:ok, "S"} = :gen_tcp.recv(tcp, 1, 5000)
{:ok, ssl} = :ssl.connect(tcp, [verify: :verify_none, active: false], 5000)

send_startup(:ssl, ssl, ctx)

{:ok, <<?R, _::32, 3::32>>} = :ssl.recv(ssl, 0, 5000)

pw = @password <> <<0>>
:ok = :ssl.send(ssl, [<<?p, byte_size(pw) + 4::32>>, pw])

recv_until_ready_for_query(:ssl, ssl, "")

:ok = :ssl.send(ssl, :pgo_protocol.encode_query_message("SELECT 1"))
{:ok, data} = :ssl.recv(ssl, 0, 5000)
assert_data_row(data)
:ssl.close(ssl)
end

defp connect_origin(db_conf) do
Postgrex.start_link(
hostname: db_conf[:hostname],
port: db_conf[:port],
database: db_conf[:database],
password: db_conf[:password],
username: db_conf[:username]
)
end

defp send_startup(transport, sock, ctx) do
startup =
:pgo_protocol.encode_startup_message([
{"user", ctx.username},
{"database", to_string(ctx.db_conf[:database])}
])

:ok = transport.send(sock, startup)
end

# Mirrors `:pgo_scram.get_client_final/4` but routes the password through
# `PgSASLprep` instead of the buggy `:pgo_sasl_prep_profile.validate/1`.
defp do_scram_exchange(transport, sock, user, password) do
{:ok, <<?R, _::32, 10::32, methods_bin::binary>>} = transport.recv(sock, 0, 5000)
assert "SCRAM-SHA-256" in :pgo_protocol.decode_strings(methods_bin)

nonce = :pgo_scram.get_nonce(16)
client_first = :pgo_scram.get_client_first(user, nonce)
client_first_size = :erlang.iolist_size(client_first)
sasl_initial = ["SCRAM-SHA-256", 0, <<client_first_size::32>>, client_first]
:ok = transport.send(sock, :pgo_protocol.encode_scram_response_message(sasl_initial))

{:ok, <<?R, _::32, 11::32, server_first::binary>>} = transport.recv(sock, 0, 5000)
sf = :pgo_scram.parse_server_first(server_first, nonce)
salt = :proplists.get_value(:salt, sf)
i = :proplists.get_value(:i, sf)
server_first_raw = :proplists.get_value(:raw, sf)
server_nonce = :proplists.get_value(:nonce, sf)

salted_password = :pgo_scram.hi(PgSASLprep.scram_normalize(password), salt, i)
client_key = :pgo_scram.hmac(salted_password, "Client Key")
stored_key = :pgo_scram.h(client_key)
client_first_bare = ["n=", user, ",r=", nonce]
client_final_no_proof = ["c=biws,r=", server_nonce]
auth_message = [client_first_bare, ",", server_first_raw, ",", client_final_no_proof]
client_signature = :pgo_scram.hmac(stored_key, auth_message)
client_proof = :pgo_scram.bin_xor(client_key, client_signature)
server_key = :pgo_scram.hmac(salted_password, "Server Key")
server_proof = :pgo_scram.hmac(server_key, auth_message)

client_final = [client_final_no_proof, ",p=", Base.encode64(client_proof)]
:ok = transport.send(sock, :pgo_protocol.encode_scram_response_message(client_final))

{:ok, auth_data} = transport.recv(sock, 0, 5000)
{pkts, rest} = Supavisor.Protocol.split_pkts(auth_data)
assert rest == ""
[<<?R, _::32, 12::32, server_final::binary>> | tail] = pkts
{:ok, ^server_proof} = :pgo_scram.parse_server_final(server_final)
IO.iodata_to_binary(tail)
end

defp assert_data_row(data) do
{pkts, _} = Supavisor.Protocol.split_pkts(data)
assert Enum.any?(pkts, &match?(<<?D, _::binary>>, &1))
end
end

defp recv_until_ready_for_query(transport \\ :gen_tcp, sock, buf) do
{pkts, rest} = Supavisor.Protocol.split_pkts(buf)

if Enum.any?(pkts, &match?(<<?Z, _::binary>>, &1)) do
:ok
else
{:ok, more} = :gen_tcp.recv(sock, 0, 5000)
recv_until_ready_for_query(sock, more)
{:ok, more} = transport.recv(sock, 0, 5000)
recv_until_ready_for_query(transport, sock, rest <> more)
end
end
end
Loading