Browse Source

SCRAM-SHA-256 auth method prototype. #142

Сергей Прохоров 7 years ago
parent
commit
f6e8accc32

+ 1 - 0
include/protocol.hrl

@@ -16,6 +16,7 @@
 -define(SIMPLEQUERY, $Q).
 -define(AUTHENTICATION_REQUEST, $R).
 -define(SYNC, $S).
+-define(SASL_ANY_RESPONSE, $p).
 
 %% Parameters
 

+ 80 - 18
src/commands/epgsql_cmd_connect.erl

@@ -20,9 +20,19 @@
 
 -record(connect,
         {opts :: list(),
-         auth_method,
+         auth_fun :: fun() | undefined,
+         auth_state :: any() | undefined,
+         auth_send :: {integer(), iodata()} | undefined,
          stage = connect :: connect | auth | initialization}).
 
+-define(SCRAM_AUTH_METHOD, <<"SCRAM-SHA-256">>).
+-define(AUTH_OK, 0).
+-define(AUTH_CLEARTEXT, 3).
+-define(AUTH_MD5, 5).
+-define(AUTH_SASL, 10).
+-define(AUTH_SASL_CONTINUE, 11).
+-define(AUTH_SASL_FINAL, 12).
+
 init({Host, Username, Password, Opts}) ->
     Opts1 = [{host, Host},
              {username, Username},
@@ -77,17 +87,9 @@ execute(PgSock, #connect{opts = Opts, stage = connect} = State) ->
         {error, Reason} = Error ->
             {stop, Reason, Error, PgSock}
     end;
-execute(PgSock, #connect{stage = auth, auth_method = cleartext, opts = Opts} = St) ->
-    Password = get_val(password, Opts),
-    epgsql_sock:send(PgSock, ?PASSWORD, [Password, 0]),
-    {ok, PgSock, St};
-execute(PgSock, #connect{stage = auth, auth_method = {md5, Salt}, opts = Opts} = St) ->
-    User = get_val(username, Opts),
-    Password = get_val(password, Opts),
-    Digest1 = hex(erlang:md5([Password, User])),
-    Str = ["md5", hex(erlang:md5([Digest1, Salt])), 0],
-    epgsql_sock:send(PgSock, ?PASSWORD, Str),
-    {ok, PgSock, St}.
+execute(PgSock, #connect{stage = auth, auth_send = {PacketId, Data}} = St) ->
+    epgsql_sock:send(PgSock, PacketId, Data),
+    {ok, PgSock, St#connect{auth_send = undefined}}.
 
 
 maybe_ssl(S, false, _, PgSock) ->
@@ -114,19 +116,79 @@ maybe_ssl(S, Flag, Opts, PgSock) ->
             end
     end.
 
+
+%% Auth sub-protocol
+
+auth_init(Fun, InitState, PgSock, St) ->
+    auth_handle(init, PgSock, St#connect{auth_fun = Fun, auth_state = InitState,
+                                                    stage = auth}).
+
+auth_handle(Data, PgSock, #connect{auth_fun = Fun, auth_state = AuthSt} = St) ->
+    case Fun(Data, AuthSt, St) of
+        {send, SendPacketId, SendData, AuthSt1} ->
+            {requeue, PgSock, St#connect{auth_state = AuthSt1,
+                                         auth_send = {SendPacketId, SendData}}};
+        ok ->
+            {noaction, PgSock, St}
+    end.
+
+auth_cleartext(init, _AuthState, #connect{opts = Opts}) ->
+    Password = get_val(password, Opts),
+    {send, ?PASSWORD, [Password, 0], undefined};
+auth_cleartext(_, _, _) -> unknown.
+
+auth_md5(init, Salt, #connect{opts = Opts}) ->
+    User = get_val(username, Opts),
+    Password = get_val(password, Opts),
+    Digest1 = hex(erlang:md5([Password, User])),
+    Str = ["md5", hex(erlang:md5([Digest1, Salt])), 0],
+    {send, ?PASSWORD, Str, undefined};
+auth_md5(_, _, _) -> unknown.
+
+
+auth_scram(init, undefined, #connect{opts = Opts}) ->
+    User = get_val(username, Opts),
+    Nonce = epgsql_scram:get_nonce(10),
+    ClientFirst = epgsql_scram:get_client_first(User, Nonce),
+    SaslInitialResponse = [?SCRAM_AUTH_METHOD, 0, <<(iolist_size(ClientFirst)):?int32>>, ClientFirst],
+    {send, ?SASL_ANY_RESPONSE, SaslInitialResponse, {auth_request, Nonce}};
+auth_scram(<<?AUTH_SASL_CONTINUE:?int32, ServerFirst/binary>>, {auth_request, Nonce}, #connect{opts = Opts}) ->
+    User = get_val(username, Opts),
+    Password = get_val(password, Opts),
+    ServerFirstParts = epgsql_scram:parse_server_first(ServerFirst, Nonce),
+    {ClientFinalMessage, ServerProof} = epgsql_scram:get_client_final(ServerFirstParts, Nonce, User, Password),
+    {send, ?SASL_ANY_RESPONSE, ClientFinalMessage, {server_final, ServerProof}};
+auth_scram(_Msg, {server_final, _ServerProof}, _Conn) ->
+    ok.
+
+
 %% --- Auth ---
 
 %% AuthenticationOk
-handle_message(?AUTHENTICATION_REQUEST, <<0:?int32>>, Sock, State) ->
+handle_message(?AUTHENTICATION_REQUEST, <<?AUTH_OK:?int32>>, Sock, State) ->
     {noaction, Sock, State#connect{stage = initialization}};
 
 %% AuthenticationCleartextPassword
-handle_message(?AUTHENTICATION_REQUEST, <<3:?int32>>, Sock, St) ->
-    {requeue, Sock, St#connect{stage = auth, auth_method = cleartext}};
+handle_message(?AUTHENTICATION_REQUEST, <<?AUTH_CLEARTEXT:?int32>>, Sock, St) ->
+    auth_init(fun auth_cleartext/3, undefined, Sock, St);
 
 %% AuthenticationMD5Password
-handle_message(?AUTHENTICATION_REQUEST, <<5:?int32, Salt:4/binary>>, Sock, St) ->
-    {requeue, Sock, St#connect{stage = auth, auth_method = {md5, Salt}}};
+handle_message(?AUTHENTICATION_REQUEST, <<?AUTH_MD5:?int32, Salt:4/binary>>, Sock, St) ->
+    auth_init(fun auth_md5/3, Salt, Sock, St);
+
+%% AuthenticationSASL
+handle_message(?AUTHENTICATION_REQUEST, <<?AUTH_SASL:?int32, MethodsB/binary>>, Sock, St) ->
+    Methods = epgsql_wire:decode_strings(MethodsB),
+    case lists:member(?SCRAM_AUTH_METHOD, Methods) of
+        true ->
+            auth_init(fun auth_scram/3, undefined, Sock, St);
+        false ->
+            {stop, normal, {error, {unsupported_auth_method,
+                                    lists:delete(<<>>, Methods)}}}
+    end;
+
+handle_message(?AUTHENTICATION_REQUEST, Packet, Sock, #connect{stage = auth} = St) ->
+    auth_handle(Packet, Sock, St);
 
 handle_message(?AUTHENTICATION_REQUEST, <<M:?int32, _/binary>>, Sock, _State) ->
     Method = case M of
@@ -135,7 +197,7 @@ handle_message(?AUTHENTICATION_REQUEST, <<M:?int32, _/binary>>, Sock, _State) ->
         6 -> scm;
         7 -> gss;
         8 -> sspi;
-        _ -> unknown
+        _ -> {unknown, M}
     end,
     {stop, normal, {error, {unsupported_auth_method, Method}}, Sock};
 

+ 123 - 0
src/epgsql_scram.erl

@@ -0,0 +1,123 @@
+%%% @doc
+%%% SCRAM--SHA-256 helper functions
+%%% See
+%%% https://www.postgresql.org/docs/current/static/sasl-authentication.html
+%%% https://en.wikipedia.org/wiki/Salted_Challenge_Response_Authentication_Mechanism
+%%% https://tools.ietf.org/html/rfc7677
+%%% https://tools.ietf.org/html/rfc5802
+%%% @end
+
+-module(epgsql_scram).
+-export([get_nonce/1,
+         get_client_first/2,
+         get_client_final/4,
+         parse_server_first/2]).
+-export([hi/3,
+         hmac/2,
+         h/1,
+         bin_xor/2]).
+
+get_client_first(UserName, Nonce) ->
+    %% Username is ignored by postgresql
+    [<<"n,,">> | client_first_bare(UserName, Nonce)].
+
+client_first_bare(UserName, Nonce) ->
+    [<<"n=">>, UserName, <<",r=">>, Nonce].
+
+get_nonce(Len) ->
+    Nonce = crypto:strong_rand_bytes(Len),
+    base64:encode(Nonce).
+
+parse_server_first(ServerFirst, ClientNonce) ->
+    PartsB = binary:split(ServerFirst, <<",">>, [global]),
+    (length(PartsB) == 3) orelse error({invalid_server_first, ServerFirst}),
+    Parts =
+        lists:map(
+          fun(<<"r=", R/binary>>) ->
+                  {nonce, R};
+             (<<"s=", S/binary>>) ->
+                  {salt, base64:decode(S)};
+             (<<"i=", I/binary>>) ->
+                  {i, binary_to_integer(I)}
+          end, PartsB),
+    check_nonce(ClientNonce, proplists:get_value(nonce, Parts)),
+    [{raw, ServerFirst} | Parts].
+
+%% SaltedPassword  := Hi(Normalize(password), salt, i)
+%% ClientKey       := HMAC(SaltedPassword, "Client Key")
+%% StoredKey       := H(ClientKey)
+%% AuthMessage     := client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof
+%% ClientSignature := HMAC(StoredKey, AuthMessage)
+%% ClientProof     := ClientKey XOR ClientSignature
+get_client_final(SrvFirst, ClientNonce, UserName, Password) ->
+    ChannelBinding = <<"c=biws">>,                 %channel-binding isn't implemented
+    Nonce = [<<"r=">>, proplists:get_value(nonce, SrvFirst)],
+
+    Salt = proplists:get_value(salt, SrvFirst),
+    I = proplists:get_value(i, SrvFirst),
+
+    SaltedPassword = hi(normalize(Password), Salt, I),
+    ClientKey = hmac(SaltedPassword, "Client Key"),
+    StoredKey = h(ClientKey),
+    ClientFirstBare = client_first_bare(UserName, ClientNonce),
+    ServerFirst = proplists:get_value(raw, SrvFirst),
+    ClientFinalWithoutProof = [ChannelBinding, ",", Nonce],
+    AuthMessage = [ClientFirstBare, ",", ServerFirst, ",", ClientFinalWithoutProof],
+    ClientSignature = hmac(StoredKey, AuthMessage),
+    ClientProof = bin_xor(ClientKey, ClientSignature),
+
+    ServerKey = hmac(SaltedPassword, "Server Key"),
+    ServerSignature = hmac(ServerKey, AuthMessage),
+
+    {[ClientFinalWithoutProof, ",p=", base64:encode(ClientProof)], ServerSignature}.
+
+%% Helpers
+
+%% TODO: implement
+normalize(Str) ->
+    Str.
+
+check_nonce(ClientNonce, ServerNonce) ->
+    Size = size(ClientNonce),
+    <<ClientNonce:Size/binary, _/binary>> = ServerNonce,
+    true.
+
+hi(Str, Salt, I) ->
+    U1 = hmac(Str, <<Salt/binary, 1:32/integer-big>>),
+    hi1(Str, U1, U1, I - 1).
+
+hi1(_Str, _U, Hi, 0) ->
+    Hi;
+hi1(Str, U, Hi, I) ->
+    U2 = hmac(Str, U),
+    Hi1 = bin_xor(Hi, U2),
+    hi1(Str, U2, Hi1, I - 1).
+
+hmac(Key, Str) ->
+    crypto:hmac(sha256, Key, Str).
+
+h(Str) ->
+    crypto:hash(sha256, Str).
+
+%% word 'xor' is reserved
+bin_xor(B1, B2) ->
+    crypto:exor(B1, B2).
+
+-ifdef(TEST).
+-include_lib("eunit/include/eunit.hrl").
+
+exchange_test() ->
+    Password = <<"foobar">>,
+    Nonce = <<"9IZ2O01zb9IgiIZ1WJ/zgpJB">>,
+    Username = <<>>,
+
+    ClientFirst = <<"n,,n=,r=9IZ2O01zb9IgiIZ1WJ/zgpJB">>,
+    ServerFirst = <<"r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,s=fs3IXBy7U7+IvVjZ,i=4096">>,
+    ClientFinal = <<"c=biws,r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,p=AmNKosjJzS31NTlQYNs5BTeQjdHdk7lOflDo5re2an8=">>,
+    _ServerFinal = "v=U+ppxD5XUKtradnv8e2MkeupiA8FU87Sg8CXzXHDAzw=",
+
+    ?assertEqual(ClientFirst, iolist_to_binary(get_client_first(Username, Nonce))),
+    SF = parse_server_first(ServerFirst, Nonce),
+    {CF, _} = get_client_final(SF, Nonce, Username, Password),
+    ?assertEqual(ClientFinal, iolist_to_binary(CF), CF).
+-endif.

+ 4 - 0
test/data/test_schema.sql

@@ -9,6 +9,9 @@
 
 CREATE USER epgsql_test;
 CREATE USER epgsql_test_md5 WITH PASSWORD 'epgsql_test_md5';
+SET password_encryption TO 'scram-sha-256';
+CREATE USER epgsql_test_scram WITH PASSWORD 'epgsql_test_scram';
+SET password_encryption TO 'md5';
 CREATE USER epgsql_test_cleartext WITH PASSWORD 'epgsql_test_cleartext';
 CREATE USER epgsql_test_cert;
 CREATE USER epgsql_test_replication WITH REPLICATION PASSWORD 'epgsql_test_replication';
@@ -18,6 +21,7 @@ CREATE DATABASE epgsql_test_db2 WITH ENCODING 'UTF8';
 
 GRANT ALL ON DATABASE epgsql_test_db1 to epgsql_test;
 GRANT ALL ON DATABASE epgsql_test_db1 to epgsql_test_md5;
+GRANT ALL ON DATABASE epgsql_test_db1 to epgsql_test_scram;
 GRANT ALL ON DATABASE epgsql_test_db1 to epgsql_test_cleartext;
 GRANT ALL ON DATABASE epgsql_test_db2 to epgsql_test;
 

+ 8 - 0
test/epgsql_SUITE.erl

@@ -46,6 +46,7 @@ groups() ->
             connect_as,
             connect_with_cleartext,
             connect_with_md5,
+            connect_with_scram,
             connect_with_invalid_user,
             connect_with_invalid_password,
             connect_with_ssl,
@@ -195,6 +196,13 @@ connect_with_md5(Config) ->
         [{database, "epgsql_test_db1"}]
     ]).
 
+connect_with_scram(Config) ->
+    epgsql_ct:connect_only(Config, [
+        "epgsql_test_scram",
+        "epgsql_test_scram",
+        [{database, "epgsql_test_db1"}]
+    ]).
+
 connect_with_invalid_user(Config) ->
     {Host, Port} = epgsql_ct:connection_data(Config),
     Module = ?config(module, Config),

+ 1 - 0
test/epgsql_cth.erl

@@ -196,6 +196,7 @@ write_pg_hba_config(Config) ->
         "host    epgsql_test_db1 ", User, "              127.0.0.1/32    trust\n",
         "host    epgsql_test_db1 epgsql_test             127.0.0.1/32    trust\n",
         "host    epgsql_test_db1 epgsql_test_md5         127.0.0.1/32    md5\n",
+        "host    epgsql_test_db1 epgsql_test_scram       127.0.0.1/32    scram-sha-256\n",
         "host    epgsql_test_db1 epgsql_test_cleartext   127.0.0.1/32    password\n",
         "hostssl epgsql_test_db1 epgsql_test_cert        127.0.0.1/32    cert clientcert=1\n",
         "host    replication     epgsql_test_replication 127.0.0.1/32    trust"