You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by rn...@apache.org on 2023/11/06 17:09:52 UTC

(couchdb) branch decouple_offline_hash_strength_from_online updated (0001ea38f -> 125eddfb2)

This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a change to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git


    omit 0001ea38f update Makefile.win to match
    omit 9682d283a increase default password iterations to 50000
    omit c27bf7c1a Password hash upgrade tests
    omit 8b3d07516 improve assertion failure messages
    omit 06c486769 remove all password derivation fields before adding back the new ones
    omit 748678b6d Enhance dev/run to use pbkdf2 with sha256
    omit e742cf842 upgrade password on next session or basic auth
    omit 5dd618535 give couch_password_hasher:hash a clearer name
    omit 30147b583 use couch password cache
    omit 7104360b5 in-memory password hash cache
    omit d79ea8ae2 Introduce pbkdf2_prf parameter
    omit a88e56733 optimize couch_passwords:verify for OTP 25+
    omit 5e5fed825 switch PBKDF2 to fast_pbkdf2
     new b54dd9cc8 switch PBKDF2 to fast_pbkdf2
     new d16e2e1f8 optimize couch_passwords:verify for OTP 25+
     new 9933a6ffd Introduce pbkdf2_prf parameter
     new fe251220f in-memory password hash cache
     new 7b52ef56d use couch password cache
     new b0150f955 give couch_password_hasher:hash a clearer name
     new 90d806312 upgrade password on next session or basic auth
     new 30b566233 Enhance dev/run to use pbkdf2 with sha256
     new 8393aaea8 remove all password derivation fields before adding back the new ones
     new 5fa15c571 improve assertion failure messages
     new 8e973354c Password hash upgrade tests
     new fb24d0ce0 increase default password iterations to 50000
     new 125eddfb2 update Makefile.win to match

This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version.  This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:

 * -- * -- B -- O -- O -- O   (0001ea38f)
            \
             N -- N -- N   refs/heads/decouple_offline_hash_strength_from_online (125eddfb2)

You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.

Any revisions marked "omit" are not gone; other references still
refer to them.  Any revisions marked "discard" are gone forever.

The 13 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .gitignore          | 1 +
 rebar.config.script | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)


(couchdb) 11/13: Password hash upgrade tests

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 8e973354cd5f5c39279ddf55b281bbba0b7c0163
Author: Robert Newson <rn...@apache.org>
AuthorDate: Fri Oct 27 14:00:22 2023 +0100

    Password hash upgrade tests
---
 test/elixir/lib/couch/dbtest.ex             |   8 +-
 test/elixir/test/config/skip.elixir         |   3 -
 test/elixir/test/helper_test.exs            |   2 +-
 test/elixir/test/users_db_security_test.exs | 120 +++++++++++++++++++++++-----
 4 files changed, 106 insertions(+), 27 deletions(-)

diff --git a/test/elixir/lib/couch/dbtest.ex b/test/elixir/lib/couch/dbtest.ex
index ae59da09f..a833582a7 100644
--- a/test/elixir/lib/couch/dbtest.ex
+++ b/test/elixir/lib/couch/dbtest.ex
@@ -416,11 +416,11 @@ defmodule Couch.DBTest do
     retry_until(condition, now(:ms), sleep, timeout)
   end
 
-  defp retry_until(condition, start, sleep, timeout) do
+  defp retry_until(condition, start, sleep, timeout, err \\ nil) do
     now = now(:ms)
 
     if now > start + timeout do
-      raise "timed out after #{now - start} ms"
+      raise err
     else
       try do
         if result = condition.() do
@@ -429,9 +429,9 @@ defmodule Couch.DBTest do
           raise ExUnit.AssertionError
         end
       rescue
-        ExUnit.AssertionError ->
+        e in ExUnit.AssertionError ->
           :timer.sleep(sleep)
-          retry_until(condition, start, sleep, timeout)
+          retry_until(condition, start, sleep, timeout, e)
       end
     end
   end
diff --git a/test/elixir/test/config/skip.elixir b/test/elixir/test/config/skip.elixir
index 7fbcacef7..be427a740 100644
--- a/test/elixir/test/config/skip.elixir
+++ b/test/elixir/test/config/skip.elixir
@@ -13,9 +13,6 @@
   "SecurityValidationTest": [
     "Author presence and user security when replicated"
   ],
-  "UsersDbSecurityTest": [
-    "user db security"
-  ],
   "WithQuorumTest": [
     "Creating/Deleting DB should return 201-Created/202-Acepted"
   ],
diff --git a/test/elixir/test/helper_test.exs b/test/elixir/test/helper_test.exs
index 19d70eac8..e3702a824 100644
--- a/test/elixir/test/helper_test.exs
+++ b/test/elixir/test/helper_test.exs
@@ -18,7 +18,7 @@ defmodule HelperTest do
   end
 
   test "retry_until times out", _context do
-    assert_raise RuntimeError, ~r/^timed out after \d+ ms$/, fn ->
+    assert_raise ExUnit.AssertionError, "\n\nExpected truthy, got false\ncode: assert false\n", fn ->
       retry_until(
         fn ->
           assert false
diff --git a/test/elixir/test/users_db_security_test.exs b/test/elixir/test/users_db_security_test.exs
index 656749040..428541353 100644
--- a/test/elixir/test/users_db_security_test.exs
+++ b/test/elixir/test/users_db_security_test.exs
@@ -91,10 +91,11 @@ defmodule UsersDbSecurityTest do
   defp save_as(db_name, doc, options) do
     use_session = Keyword.get(options, :use_session)
     user = Keyword.get(options, :user)
+    pwd = Keyword.get(options, :pwd)
     expect_response = Keyword.get(options, :expect_response, [201, 202])
     expect_message = Keyword.get(options, :error_message)
 
-    session = use_session || login_as(user)
+    session = use_session || login_as(user, pwd)
 
     resp =
       Couch.Session.put(
@@ -276,7 +277,7 @@ defmodule UsersDbSecurityTest do
     # Exising 'derived_key' and 'salt' fields are overwritten with new values
     # when a non-null 'password' field exists.
     # anonymous should be able to create a user document
-    user_doc = %{
+    tom_doc1 = %{
       _id: "org.couchdb.user:tom",
       type: "user",
       name: "tom",
@@ -285,18 +286,18 @@ defmodule UsersDbSecurityTest do
     }
 
     resp =
-      Couch.post("/#{@users_db}", body: user_doc, headers: [authorization: "annonymous"])
+      Couch.post("/#{@users_db}", body: tom_doc1, headers: [authorization: "annonymous"])
 
     assert resp.status_code in [201, 202]
     assert resp.body["ok"]
 
-    user_doc =
+    _tom_doc2 =
       retry_until(fn ->
-        user_doc = open_as(@users_db, "org.couchdb.user:tom", user: "tom")
-        assert !user_doc["password"]
-        assert String.length(user_doc["derived_key"]) == 40
-        assert String.length(user_doc["salt"]) == 32
-        user_doc
+        doc = open_as(@users_db, "org.couchdb.user:tom", user: "tom")
+        assert !doc["password"]
+        assert String.length(doc["derived_key"]) == 64
+        assert String.length(doc["salt"]) == 32
+        doc
       end)
 
     # anonymous should not be able to read an existing user's user document
@@ -313,8 +314,8 @@ defmodule UsersDbSecurityTest do
     assert resp.body["error"] == "unauthorized"
 
     # user should be able to read their own document
-    tom_doc = open_as(@users_db, "org.couchdb.user:tom", user: "tom")
-    assert tom_doc["_id"] == "org.couchdb.user:tom"
+    tom_doc3 = open_as(@users_db, "org.couchdb.user:tom", user: "tom")
+    assert tom_doc3["_id"] == "org.couchdb.user:tom"
 
     # user should not be able to read /_users/_changes
     changes_as(@users_db,
@@ -323,15 +324,96 @@ defmodule UsersDbSecurityTest do
       expect_message: "unauthorized"
     )
 
-    tom_doc = Map.put(tom_doc, "password", "couch")
-    save_as(@users_db, tom_doc, user: "tom")
+    # changing password regenerates derived_key and salt
+    tom_doc4 = Map.put(tom_doc3, "password", "couch")
+    save_as(@users_db, tom_doc4, user: "tom")
+
+    _tom_doc5 =
+      retry_until(fn ->
+        doc = open_as(@users_db, "org.couchdb.user:tom", user: "jerry")
+        assert !doc["password"]
+        assert String.length(doc["derived_key"]) == 64
+        assert String.length(doc["salt"]) == 32
+        assert doc["iterations"] == 1
+        assert doc["pbkdf2_prf"] == "sha256"
+        assert doc["derived_key"] != tom_doc4["derived_key"]
+        assert doc["salt"] != tom_doc4["salt"]
+        doc
+      end)
+
+    # changing iteration count regenerates derived_key, salt and iterations
+    # on next password change
+    set_config({
+      "chttpd_auth",
+      "iterations",
+      "3"
+    })
+
+    tom_doc5 = Map.put(tom_doc4, "password", "couch")
+    # 201 if the save does the update, 409 if the async password hasher got there first.
+    save_as(@users_db, tom_doc5, user: "tom", pwd: "couch", expect_response: [201, 409])
 
-    tom_doc = open_as(@users_db, "org.couchdb.user:tom", user: "jerry")
-    assert !tom_doc["password"]
-    assert String.length(tom_doc["derived_key"]) == 40
-    assert String.length(tom_doc["salt"]) == 32
-    assert tom_doc["derived_key"] != user_doc["derived_key"]
-    assert tom_doc["salt"] != user_doc["salt"]
+    tom_doc6 =
+      retry_until(fn ->
+        doc = open_as(@users_db, "org.couchdb.user:tom", user: "jerry")
+        assert !doc["password"]
+        assert String.length(doc["derived_key"]) == 64
+        assert String.length(doc["salt"]) == 32
+        assert doc["iterations"] == 3
+        assert doc["pbkdf2_prf"] == "sha256"
+        assert doc["derived_key"] != tom_doc5["derived_key"]
+        assert doc["salt"] != tom_doc5["salt"]
+        doc
+      end)
+
+    # changing PRF regenerates derived_key and salt on next
+    # password change
+    set_config({
+      "chttpd_auth",
+      "pbkdf2_prf",
+      "sha512"
+    })
+
+    tom_doc7 = Map.put(tom_doc6, "password", "couch")
+    # 201 if the save does the update, 409 if the async password hasher got there first.
+    save_as(@users_db, tom_doc7, user: "tom", pwd: "couch", expect_response: [201, 409])
+
+    tom_doc8 =
+      retry_until(fn ->
+        doc = open_as(@users_db, "org.couchdb.user:tom", user: "jerry")
+        assert !doc["password"]
+        assert String.length(doc["derived_key"]) == 128
+        assert String.length(doc["salt"]) == 32
+        assert doc["iterations"] == 3
+        assert doc["pbkdf2_prf"] == "sha512"
+        assert doc["derived_key"] != tom_doc7["derived_key"]
+        assert doc["salt"] != tom_doc7["salt"]
+        doc
+      end)
+
+    # changing iterations regenerates derived_key and preserves salt
+    # on next successful authentication
+    set_config({
+      "chttpd_auth",
+      "iterations",
+      "4"
+    })
+
+    _tom_doc9 =
+      retry_until(fn ->
+        # login inside the loop as we need to trigger the async
+        # hasher each time
+        assert login_as("tom", "couch")
+        doc = open_as(@users_db, "org.couchdb.user:tom", user: "jerry")
+        assert !doc["password"]
+        assert String.length(doc["derived_key"]) == 128
+        assert String.length(doc["salt"]) == 32
+        assert doc["iterations"] == 4
+        assert doc["pbkdf2_prf"] == "sha512"
+        assert doc["derived_key"] != tom_doc8["derived_key"]
+        assert doc["salt"] == tom_doc8["salt"]
+        doc
+      end)
 
     # user should not be able to read another user's user document
     spike_doc = %{


(couchdb) 06/13: give couch_password_hasher:hash a clearer name

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit b0150f9557e8127579ca6737364e27bba3ba0926
Author: Robert Newson <rn...@apache.org>
AuthorDate: Thu Oct 19 14:18:44 2023 +0100

    give couch_password_hasher:hash a clearer name
---
 src/couch/src/couch_password_hasher.erl | 14 +++++++-------
 src/couch/src/couch_server.erl          |  2 +-
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/src/couch/src/couch_password_hasher.erl b/src/couch/src/couch_password_hasher.erl
index 000497694..b34bc52d9 100644
--- a/src/couch/src/couch_password_hasher.erl
+++ b/src/couch/src/couch_password_hasher.erl
@@ -24,7 +24,7 @@
     code_change/3
 ]).
 
--export([hash/1]).
+-export([hash_admin_passwords/1]).
 
 -record(state, {}).
 
@@ -32,9 +32,9 @@
 %%% Public functions
 %%%===================================================================
 
--spec hash(Persist :: boolean()) -> Reply :: term().
-hash(Persist) ->
-    gen_server:cast(?MODULE, {hash_passwords, Persist}).
+-spec hash_admin_passwords(Persist :: boolean()) -> Reply :: term().
+hash_admin_passwords(Persist) ->
+    gen_server:cast(?MODULE, {hash_admin_passwords, Persist}).
 
 %%%===================================================================
 %%% Spawning and gen_server implementation
@@ -50,8 +50,8 @@ init(_Args) ->
 handle_call(Msg, _From, #state{} = State) ->
     {stop, {invalid_call, Msg}, {invalid_call, Msg}, State}.
 
-handle_cast({hash_passwords, Persist}, State) ->
-    hash_admin_passwords(Persist),
+handle_cast({hash_admin_passwords, Persist}, State) ->
+    hash_admin_passwords_int(Persist),
     {noreply, State};
 handle_cast(Msg, State) ->
     {stop, {invalid_cast, Msg}, State}.
@@ -63,7 +63,7 @@ code_change(_OldVsn, #state{} = State, _Extra) ->
 %%% Internal functions
 %%%===================================================================
 
-hash_admin_passwords(Persist) ->
+hash_admin_passwords_int(Persist) ->
     lists:foreach(
         fun({User, ClearPassword}) ->
             HashedPassword = couch_passwords:hash_admin_password(ClearPassword),
diff --git a/src/couch/src/couch_server.erl b/src/couch/src/couch_server.erl
index 7dbbe4af1..94f36baab 100644
--- a/src/couch/src/couch_server.erl
+++ b/src/couch/src/couch_server.erl
@@ -357,7 +357,7 @@ handle_config_change("couchdb_engines", _, _, _, N) ->
     {ok, N};
 handle_config_change("admins", _, _, Persist, 1 = N) ->
     % async hashing on couch_server with number 1 only
-    couch_password_hasher:hash(Persist),
+    couch_password_hasher:hash_admin_passwords(Persist),
     {ok, N};
 handle_config_change("httpd", "authentication_handlers", _, _, 1 = N) ->
     couch_httpd:stop(),


(couchdb) 02/13: optimize couch_passwords:verify for OTP 25+

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit d16e2e1f8992f1f0715fb779efc0bf3669925469
Author: Robert Newson <rn...@apache.org>
AuthorDate: Tue Oct 24 13:32:20 2023 +0100

    optimize couch_passwords:verify for OTP 25+
---
 src/couch/src/couch_passwords.erl | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/src/couch/src/couch_passwords.erl b/src/couch/src/couch_passwords.erl
index 45b335f99..eb1c27a72 100644
--- a/src/couch/src/couch_passwords.erl
+++ b/src/couch/src/couch_passwords.erl
@@ -117,6 +117,14 @@ pbkdf2(Password, Salt, Iterations, DerivedLength) when
     {ok, couch_util:to_hex_bin(DerivedKey)}.
 
 %% verify two lists for equality without short-circuits to avoid timing attacks.
+-if((?OTP_RELEASE) >= 25).
+verify(ListA, ListB) when is_list(ListA), is_list(ListB) ->
+    verify(?l2b(ListA), ?l2b(ListB));
+verify(BinA, BinB) when is_binary(BinA), is_binary(BinB), byte_size(BinA) == byte_size(BinB) ->
+    crypto:hash_equals(BinA, BinB);
+verify(BinA, BinB) when is_binary(BinA), is_binary(BinB) ->
+    false.
+-else.
 -spec verify(string(), string(), integer()) -> boolean().
 verify([X | RestX], [Y | RestY], Result) ->
     verify(RestX, RestY, (X bxor Y) bor Result);
@@ -137,3 +145,4 @@ verify(X, Y) when is_list(X) and is_list(Y) ->
     end;
 verify(_X, _Y) ->
     false.
+-endif.


(couchdb) 01/13: switch PBKDF2 to fast_pbkdf2

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit b54dd9cc8070929758377c5c8da5374e7d9f517d
Author: Robert Newson <rn...@apache.org>
AuthorDate: Wed Oct 18 16:58:32 2023 +0100

    switch PBKDF2 to fast_pbkdf2
---
 .gitignore                        |  1 +
 rebar.config.script               |  3 +-
 rel/reltool.config                |  6 ++--
 src/couch/src/couch.app.src       |  1 +
 src/couch/src/couch_passwords.erl | 65 ++-------------------------------------
 5 files changed, 10 insertions(+), 66 deletions(-)

diff --git a/.gitignore b/.gitignore
index 30aed7787..b0f25b450 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,6 +52,7 @@ src/couch/priv/couch_js/**/*.d
 src/couch/priv/icu_driver/couch_icu_driver.d
 src/mango/src/mango_cursor_text.nocompile
 src/excoveralls/
+src/fast_pbkdf2/
 src/fauxton/
 src/folsom/
 src/hackney/
diff --git a/rebar.config.script b/rebar.config.script
index a7c8e8f30..8aff2243c 100644
--- a/rebar.config.script
+++ b/rebar.config.script
@@ -157,7 +157,8 @@ DepDescs = [
 {jiffy,            "jiffy",            {tag, "CouchDB-1.0.9-2"}},
 {mochiweb,         "mochiweb",         {tag, "v3.2.1"}},
 {meck,             "meck",             {tag, "0.9.2"}},
-{recon,            "recon",            {tag, "2.5.3"}}
+{recon,            "recon",            {tag, "2.5.3"}},
+{fast_pbkdf2,      "fast-pbkdf2",      {tag, "1.0.2"}}
 ].
 
 WithProper = lists:keyfind(with_proper, 1, CouchConfig) == {with_proper, true}.
diff --git a/rel/reltool.config b/rel/reltool.config
index 1dca6ff85..392d5c62c 100644
--- a/rel/reltool.config
+++ b/rel/reltool.config
@@ -66,7 +66,8 @@
 
         %% extra
         nouveau,
-        recon
+        recon,
+        fast_pbkdf2
     ]},
     {rel, "start_clean", "", [kernel, stdlib]},
     {boot_rel, "couchdb"},
@@ -129,7 +130,8 @@
 
     %% extra
     {app, nouveau, [{incl_cond, include}]},
-    {app, recon, [{incl_cond, include}]}
+    {app, recon, [{incl_cond, include}]},
+    {app, fast_pbkdf2, [{incl_cond, include}]}
 ]}.
 
 {overlay_vars, "couchdb.config"}.
diff --git a/src/couch/src/couch.app.src b/src/couch/src/couch.app.src
index 6fc293ac1..2cd14ea14 100644
--- a/src/couch/src/couch.app.src
+++ b/src/couch/src/couch.app.src
@@ -38,6 +38,7 @@
         % Upstream deps
         ibrowse,
         mochiweb,
+        fast_pbkdf2,
 
         % ASF deps
         couch_epi,
diff --git a/src/couch/src/couch_passwords.erl b/src/couch/src/couch_passwords.erl
index b89104603..45b335f99 100644
--- a/src/couch/src/couch_passwords.erl
+++ b/src/couch/src/couch_passwords.erl
@@ -113,60 +113,8 @@ pbkdf2(Password, Salt, Iterations, DerivedLength) when
     Iterations > 0,
     is_integer(DerivedLength)
 ->
-    L = ceiling(DerivedLength / ?SHA1_OUTPUT_LENGTH),
-    <<Bin:DerivedLength/binary, _/binary>> =
-        iolist_to_binary(pbkdf2(Password, Salt, Iterations, L, 1, [])),
-    {ok, couch_util:to_hex_bin(Bin)}.
-
--spec pbkdf2(binary(), binary(), integer(), integer(), integer(), iolist()) ->
-    iolist().
-pbkdf2(_Password, _Salt, _Iterations, BlockCount, BlockIndex, Acc) when
-    BlockIndex > BlockCount
-->
-    lists:reverse(Acc);
-pbkdf2(Password, Salt, Iterations, BlockCount, BlockIndex, Acc) ->
-    Block = pbkdf2(Password, Salt, Iterations, BlockIndex, 1, <<>>, <<>>),
-    pbkdf2(Password, Salt, Iterations, BlockCount, BlockIndex + 1, [Block | Acc]).
-
--spec pbkdf2(
-    binary(),
-    binary(),
-    integer(),
-    integer(),
-    integer(),
-    binary(),
-    binary()
-) -> binary().
-pbkdf2(_Password, _Salt, Iterations, _BlockIndex, Iteration, _Prev, Acc) when
-    Iteration > Iterations
-->
-    Acc;
-pbkdf2(Password, Salt, Iterations, BlockIndex, 1, _Prev, _Acc) ->
-    InitialBlock = couch_util:hmac(
-        sha,
-        Password,
-        <<Salt/binary, BlockIndex:32/integer>>
-    ),
-    pbkdf2(
-        Password,
-        Salt,
-        Iterations,
-        BlockIndex,
-        2,
-        InitialBlock,
-        InitialBlock
-    );
-pbkdf2(Password, Salt, Iterations, BlockIndex, Iteration, Prev, Acc) ->
-    Next = couch_util:hmac(sha, Password, Prev),
-    pbkdf2(
-        Password,
-        Salt,
-        Iterations,
-        BlockIndex,
-        Iteration + 1,
-        Next,
-        crypto:exor(Next, Acc)
-    ).
+    DerivedKey = fast_pbkdf2:pbkdf2(sha, Password, Salt, Iterations, DerivedLength),
+    {ok, couch_util:to_hex_bin(DerivedKey)}.
 
 %% verify two lists for equality without short-circuits to avoid timing attacks.
 -spec verify(string(), string(), integer()) -> boolean().
@@ -189,12 +137,3 @@ verify(X, Y) when is_list(X) and is_list(Y) ->
     end;
 verify(_X, _Y) ->
     false.
-
--spec ceiling(number()) -> integer().
-ceiling(X) ->
-    T = erlang:trunc(X),
-    case (X - T) of
-        Neg when Neg < 0 -> T;
-        Pos when Pos > 0 -> T + 1;
-        _ -> T
-    end.


(couchdb) 12/13: increase default password iterations to 50000

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit fb24d0ce033f49ddde963a13576324fb419faf0f
Author: Robert Newson <rn...@apache.org>
AuthorDate: Thu Nov 2 11:07:58 2023 +0000

    increase default password iterations to 50000
    
    lower for test runs as creds are unavailable until hashed and
    this would otherwise introduce test failures due to timing
---
 Makefile                          | 6 +++---
 rel/overlay/etc/default.ini       | 2 +-
 src/couch/src/couch_passwords.erl | 2 +-
 src/couch/src/couch_users_db.erl  | 2 +-
 4 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/Makefile b/Makefile
index 0d88199a9..20e8f00e1 100644
--- a/Makefile
+++ b/Makefile
@@ -92,7 +92,7 @@ DIALYZE_OPTS=$(shell echo "\
 	" | sed -e 's/[a-z]\{1,\}= / /g')
 EXUNIT_OPTS=$(subst $(comma),$(space),$(tests))
 
-TEST_OPTS="-c 'startup_jitter=0' -c 'default_security=admin_local'"
+TEST_OPTS="-c 'startup_jitter=0' -c 'default_security=admin_local' -c 'iterations=9'"
 
 ################################################################################
 # Main commands
@@ -343,7 +343,7 @@ mango-test: devclean all
 .PHONY: weatherreport-test
 # target: weatherreport-test - Run weatherreport against dev cluster
 weatherreport-test: devclean escriptize
-	@dev/run -n 1 -a adm:pass --no-eval \
+	@dev/run "$(TEST_OPTS)" -n 1 -a adm:pass --no-eval \
 		'bin/weatherreport --etc dev/lib/node1/etc --level error'
 
 ################################################################################
@@ -565,7 +565,7 @@ nouveau-test-elixir: export MIX_ENV=integration
 nouveau-test-elixir: elixir-init devclean
 nouveau-test-elixir: couch nouveau
 ifeq ($(with_nouveau), 1)
-	@dev/run -n 1 -q -a adm:pass --with-nouveau \
+	@dev/run "$(TEST_OPTS)" -n 1 -q -a adm:pass --with-nouveau \
 		--locald-config test/config/test-config.ini \
 		--no-eval 'mix test --trace --include test/elixir/test/config/nouveau.elixir'
 endif
diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 7a4417856..13d6ae2eb 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -321,7 +321,7 @@ bind_address = 127.0.0.1
 ;timeout = 600 ; number of seconds before automatic logout
 ;auth_cache_size = 50 ; size is number of cache entries
 ;allow_persistent_cookies = true ; set to false to disallow persistent cookies
-;iterations = 10 ; iterations for password hashing
+;iterations = 50000 ; iterations for password hashing
 ;min_iterations = 1
 ;max_iterations = 1000000000
 ;password_scheme = pbkdf2
diff --git a/src/couch/src/couch_passwords.erl b/src/couch/src/couch_passwords.erl
index 384a01d4a..b2f212a6d 100644
--- a/src/couch/src/couch_passwords.erl
+++ b/src/couch/src/couch_passwords.erl
@@ -44,7 +44,7 @@ hash_admin_password("simple", ClearPassword) ->
     ?l2b("-hashed-" ++ couch_util:to_hex(Hash) ++ "," ++ ?b2l(Salt));
 hash_admin_password("pbkdf2", ClearPassword) ->
     PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
-    Iterations = chttpd_util:get_chttpd_auth_config("iterations", "10"),
+    Iterations = chttpd_util:get_chttpd_auth_config("iterations", "50000"),
     Salt = couch_uuids:random(),
     DerivedKey = couch_passwords:pbkdf2(
         list_to_existing_atom(PRF),
diff --git a/src/couch/src/couch_users_db.erl b/src/couch/src/couch_users_db.erl
index c6b730209..b4728f994 100644
--- a/src/couch/src/couch_users_db.erl
+++ b/src/couch/src/couch_users_db.erl
@@ -101,7 +101,7 @@ save_doc(#doc{body = {Body}} = Doc) ->
             ok = validate_password(ClearPassword),
             PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
             Iterations = chttpd_util:get_chttpd_auth_config_integer(
-                "iterations", 10
+                "iterations", 50000
             ),
             DerivedKey = couch_passwords:pbkdf2(
                 list_to_existing_atom(PRF), ClearPassword, Salt, Iterations


(couchdb) 03/13: Introduce pbkdf2_prf parameter

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 9933a6ffd33ef75b0cef98c0a1b5fb23bce6e6af
Author: Robert Newson <rn...@apache.org>
AuthorDate: Fri Oct 20 15:08:18 2023 +0100

    Introduce pbkdf2_prf parameter
    
    default to "sha" for existing credentials
    default to "sha256" when creating new credentials
---
 rel/overlay/etc/default.ini                    |  1 +
 src/couch/src/couch_auth_cache.erl             | 24 ++++++--
 src/couch/src/couch_httpd_auth.erl             | 17 +++++-
 src/couch/src/couch_passwords.erl              | 76 +++++++++++++++-----------
 src/couch/src/couch_users_db.erl               | 17 ++++--
 src/couch/test/eunit/couch_passwords_tests.erl | 12 ++--
 src/docs/src/intro/security.rst                |  4 +-
 test/elixir/test/config_test.exs               |  2 +-
 8 files changed, 103 insertions(+), 50 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 1c94502b1..163228035 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -325,6 +325,7 @@ bind_address = 127.0.0.1
 ;min_iterations = 1
 ;max_iterations = 1000000000
 ;password_scheme = pbkdf2
+;pbkdf2_prf = sha256 ; must be one of sha | sha224 | sha256 | sha384 | sha512
 
 ; List of Erlang RegExp or tuples of RegExp and an optional error message.
 ; Where a new password must match all RegExp.
diff --git a/src/couch/src/couch_auth_cache.erl b/src/couch/src/couch_auth_cache.erl
index f361ab231..efa8e4765 100644
--- a/src/couch/src/couch_auth_cache.erl
+++ b/src/couch/src/couch_auth_cache.erl
@@ -70,15 +70,30 @@ get_admin(UserName) when is_list(UserName) ->
             % the name is an admin, now check to see if there is a user doc
             % which has a matching name, salt, and password_sha
             [HashedPwd, Salt] = string:tokens(HashedPwdAndSalt, ","),
-            make_admin_doc(HashedPwd, Salt);
+            make_admin_doc_simple(HashedPwd, Salt);
         "-pbkdf2-" ++ HashedPwdSaltAndIterations ->
             [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
-            make_admin_doc(HashedPwd, Salt, Iterations);
+            make_admin_doc_pbkdf2(<<"sha">>, HashedPwd, Salt, Iterations);
+        "-pbkdf2:sha-" ++ HashedPwdSaltAndIterations ->
+            [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
+            make_admin_doc_pbkdf2(<<"sha">>, HashedPwd, Salt, Iterations);
+        "-pbkdf2:sha224-" ++ HashedPwdSaltAndIterations ->
+            [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
+            make_admin_doc_pbkdf2(<<"sha224">>, HashedPwd, Salt, Iterations);
+        "-pbkdf2:sha256-" ++ HashedPwdSaltAndIterations ->
+            [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
+            make_admin_doc_pbkdf2(<<"sha256">>, HashedPwd, Salt, Iterations);
+        "-pbkdf2:sha384-" ++ HashedPwdSaltAndIterations ->
+            [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
+            make_admin_doc_pbkdf2(<<"sha384">>, HashedPwd, Salt, Iterations);
+        "-pbkdf2:sha512-" ++ HashedPwdSaltAndIterations ->
+            [HashedPwd, Salt, Iterations] = string:tokens(HashedPwdSaltAndIterations, ","),
+            make_admin_doc_pbkdf2(<<"sha512">>, HashedPwd, Salt, Iterations);
         _Else ->
             nil
     end.
 
-make_admin_doc(HashedPwd, Salt) ->
+make_admin_doc_simple(HashedPwd, Salt) ->
     [
         {<<"roles">>, [<<"_admin">>]},
         {<<"salt">>, ?l2b(Salt)},
@@ -86,12 +101,13 @@ make_admin_doc(HashedPwd, Salt) ->
         {<<"password_sha">>, ?l2b(HashedPwd)}
     ].
 
-make_admin_doc(DerivedKey, Salt, Iterations) ->
+make_admin_doc_pbkdf2(PRF, DerivedKey, Salt, Iterations) ->
     [
         {<<"roles">>, [<<"_admin">>]},
         {<<"salt">>, ?l2b(Salt)},
         {<<"iterations">>, list_to_integer(Iterations)},
         {<<"password_scheme">>, <<"pbkdf2">>},
+        {<<"pbkdf2_prf">>, PRF},
         {<<"derived_key">>, ?l2b(DerivedKey)}
     ].
 
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 53dcf4925..9d9afe5df 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -623,10 +623,14 @@ authenticate(Pass, UserProps) ->
                     couch_util:get_value(<<"password_sha">>, UserProps, nil)
                 };
             <<"pbkdf2">> ->
+                PRF = couch_util:get_value(<<"pbkdf2_prf">>, UserProps, <<"sha">>),
+                verify_prf(PRF),
                 Iterations = couch_util:get_value(<<"iterations">>, UserProps, 10000),
                 verify_iterations(Iterations),
                 {
-                    couch_passwords:pbkdf2(Pass, UserSalt, Iterations),
+                    couch_passwords:pbkdf2(
+                        binary_to_existing_atom(PRF), Pass, UserSalt, Iterations
+                    ),
                     couch_util:get_value(<<"derived_key">>, UserProps, nil)
                 }
         end,
@@ -648,6 +652,17 @@ verify_iterations(Iterations) when is_integer(Iterations) ->
             ok
     end.
 
+verify_prf(PRF) when
+    PRF == <<"sha">>;
+    PRF == <<"sha224">>;
+    PRF == <<"sha256">>;
+    PRF == <<"sha384">>;
+    PRF == <<"sha512">>
+->
+    ok;
+verify_prf(_PRF) ->
+    throw({forbidden, <<"PRF is invalid">>}).
+
 make_cookie_time() ->
     {NowMS, NowS, _} = os:timestamp(),
     NowMS * 1000000 + NowS.
diff --git a/src/couch/src/couch_passwords.erl b/src/couch/src/couch_passwords.erl
index eb1c27a72..384a01d4a 100644
--- a/src/couch/src/couch_passwords.erl
+++ b/src/couch/src/couch_passwords.erl
@@ -12,14 +12,11 @@
 
 -module(couch_passwords).
 
--export([simple/2, pbkdf2/3, pbkdf2/4, verify/2]).
+-export([simple/2, pbkdf2/3, pbkdf2/4, pbkdf2/5, verify/2]).
 -export([hash_admin_password/1, get_unhashed_admins/0]).
 
 -include_lib("couch/include/couch_db.hrl").
 
--define(MAX_DERIVED_KEY_LENGTH, (1 bsl 32 - 1)).
--define(SHA1_OUTPUT_LENGTH, 20).
-
 %% legacy scheme, not used for new passwords.
 -spec simple(binary(), binary()) -> binary().
 simple(Password, Salt) when is_binary(Password), is_binary(Salt) ->
@@ -46,15 +43,17 @@ hash_admin_password("simple", ClearPassword) ->
     Hash = crypto:hash(sha, <<ClearPassword/binary, Salt/binary>>),
     ?l2b("-hashed-" ++ couch_util:to_hex(Hash) ++ "," ++ ?b2l(Salt));
 hash_admin_password("pbkdf2", ClearPassword) ->
+    PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
     Iterations = chttpd_util:get_chttpd_auth_config("iterations", "10"),
     Salt = couch_uuids:random(),
     DerivedKey = couch_passwords:pbkdf2(
+        list_to_existing_atom(PRF),
         couch_util:to_binary(ClearPassword),
         Salt,
         list_to_integer(Iterations)
     ),
     ?l2b(
-        "-pbkdf2-" ++ ?b2l(DerivedKey) ++ "," ++
+        "-pbkdf2:" ++ PRF ++ "-" ++ ?b2l(DerivedKey) ++ "," ++
             ?b2l(Salt) ++ "," ++
             Iterations
     ).
@@ -69,53 +68,68 @@ get_unhashed_admins() ->
             ({_User, "-pbkdf2-" ++ _}) ->
                 % already hashed
                 false;
+            ({_User, "-pbkdf2:sha-" ++ _}) ->
+                % already hashed
+                false;
+            ({_User, "-pbkdf2:sha224-" ++ _}) ->
+                % already hashed
+                false;
+            ({_User, "-pbkdf2:sha256-" ++ _}) ->
+                % already hashed
+                false;
+            ({_User, "-pbkdf2:sha384-" ++ _}) ->
+                % already hashed
+                false;
+            ({_User, "-pbkdf2:sha512-" ++ _}) ->
+                % already hashed
+                false;
             ({_User, _ClearPassword}) ->
                 true
         end,
         config:get("admins")
     ).
 
-%% Current scheme, much stronger.
--spec pbkdf2(binary(), binary(), integer()) -> binary().
-pbkdf2(Password, Salt, Iterations) when
+pbkdf2(Password, Salt, Iterations) ->
+    pbkdf2(sha, Password, Salt, Iterations).
+
+pbkdf2(PRF, Password, Salt, Iterations) when is_atom(PRF) ->
+    #{size := Size} = crypto:hash_info(PRF),
+    pbkdf2(PRF, Password, Salt, Iterations, Size);
+pbkdf2(Password, Salt, Iterations, KeyLen) ->
+    pbkdf2(sha, Password, Salt, Iterations, KeyLen).
+
+pbkdf2(PRF, Password, Salt, Iterations, KeyLen) when
+    is_atom(PRF),
     is_binary(Password),
     is_binary(Salt),
     is_integer(Iterations),
-    Iterations > 0
+    is_integer(KeyLen),
+    Iterations > 0,
+    KeyLen > 0
 ->
-    {ok, Result} = pbkdf2(Password, Salt, Iterations, ?SHA1_OUTPUT_LENGTH),
-    Result;
-pbkdf2(Password, Salt, Iterations) when
+    DerivedKey = fast_pbkdf2:pbkdf2(PRF, Password, Salt, Iterations, KeyLen),
+    couch_util:to_hex_bin(DerivedKey);
+pbkdf2(PRF, Password, Salt, Iterations, KeyLen) when
+    is_atom(PRF),
     is_binary(Salt),
     is_integer(Iterations),
-    Iterations > 0
+    is_integer(KeyLen),
+    Iterations > 0,
+    KeyLen > 0
 ->
     Msg = io_lib:format("Password value of '~p' is invalid.", [Password]),
     throw({forbidden, Msg});
-pbkdf2(Password, Salt, Iterations) when
+pbkdf2(PRF, Password, Salt, Iterations, KeyLen) when
+    is_atom(PRF),
     is_binary(Password),
     is_integer(Iterations),
-    Iterations > 0
+    is_integer(KeyLen),
+    Iterations > 0,
+    KeyLen > 0
 ->
     Msg = io_lib:format("Salt value of '~p' is invalid.", [Salt]),
     throw({forbidden, Msg}).
 
--spec pbkdf2(binary(), binary(), integer(), integer()) ->
-    {ok, binary()} | {error, derived_key_too_long}.
-pbkdf2(_Password, _Salt, _Iterations, DerivedLength) when
-    DerivedLength > ?MAX_DERIVED_KEY_LENGTH
-->
-    {error, derived_key_too_long};
-pbkdf2(Password, Salt, Iterations, DerivedLength) when
-    is_binary(Password),
-    is_binary(Salt),
-    is_integer(Iterations),
-    Iterations > 0,
-    is_integer(DerivedLength)
-->
-    DerivedKey = fast_pbkdf2:pbkdf2(sha, Password, Salt, Iterations, DerivedLength),
-    {ok, couch_util:to_hex_bin(DerivedKey)}.
-
 %% verify two lists for equality without short-circuits to avoid timing attacks.
 -if((?OTP_RELEASE) >= 25).
 verify(ListA, ListB) when is_list(ListA), is_list(ListB) ->
diff --git a/src/couch/src/couch_users_db.erl b/src/couch/src/couch_users_db.erl
index 7ef3aee78..f8d56882a 100644
--- a/src/couch/src/couch_users_db.erl
+++ b/src/couch/src/couch_users_db.erl
@@ -23,6 +23,7 @@
 -define(SIMPLE, <<"simple">>).
 -define(PASSWORD_SHA, <<"password_sha">>).
 -define(PBKDF2, <<"pbkdf2">>).
+-define(PBKDF2_PRF, <<"pbkdf2_prf">>).
 -define(ITERATIONS, <<"iterations">>).
 -define(SALT, <<"salt">>).
 -define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})).
@@ -81,17 +82,21 @@ save_doc(#doc{body = {Body}} = Doc) ->
             Doc#doc{body = {Body3}};
         {ClearPassword, "pbkdf2"} ->
             ok = validate_password(ClearPassword),
+            PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
             Iterations = chttpd_util:get_chttpd_auth_config_integer(
                 "iterations", 10
             ),
             Salt = couch_uuids:random(),
-            DerivedKey = couch_passwords:pbkdf2(ClearPassword, Salt, Iterations),
+            DerivedKey = couch_passwords:pbkdf2(
+                list_to_existing_atom(PRF), ClearPassword, Salt, Iterations
+            ),
             Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?PBKDF2),
-            Body1 = ?replace(Body0, ?ITERATIONS, Iterations),
-            Body2 = ?replace(Body1, ?DERIVED_KEY, DerivedKey),
-            Body3 = ?replace(Body2, ?SALT, Salt),
-            Body4 = proplists:delete(?PASSWORD, Body3),
-            Doc#doc{body = {Body4}};
+            Body1 = ?replace(Body0, ?PBKDF2_PRF, ?l2b(PRF)),
+            Body2 = ?replace(Body1, ?ITERATIONS, Iterations),
+            Body3 = ?replace(Body2, ?DERIVED_KEY, DerivedKey),
+            Body4 = ?replace(Body3, ?SALT, Salt),
+            Body5 = proplists:delete(?PASSWORD, Body4),
+            Doc#doc{body = {Body5}};
         {_ClearPassword, Scheme} ->
             couch_log:error("[couch_httpd_auth] password_scheme value of '~p' is invalid.", [Scheme]),
             throw({forbidden, ?PASSWORD_SERVER_ERROR})
diff --git a/src/couch/test/eunit/couch_passwords_tests.erl b/src/couch/test/eunit/couch_passwords_tests.erl
index 6b67a99e3..a702f00c6 100644
--- a/src/couch/test/eunit/couch_passwords_tests.erl
+++ b/src/couch/test/eunit/couch_passwords_tests.erl
@@ -18,25 +18,25 @@ pbkdf2_test_() ->
     {"PBKDF2", [
         {"Iterations: 1, length: 20",
             ?_assertEqual(
-                {ok, <<"0c60c80f961f0e71f3a9b524af6012062fe037a6">>},
+                <<"0c60c80f961f0e71f3a9b524af6012062fe037a6">>,
                 couch_passwords:pbkdf2(<<"password">>, <<"salt">>, 1, 20)
             )},
 
         {"Iterations: 2, length: 20",
             ?_assertEqual(
-                {ok, <<"ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957">>},
+                <<"ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957">>,
                 couch_passwords:pbkdf2(<<"password">>, <<"salt">>, 2, 20)
             )},
 
         {"Iterations: 4096, length: 20",
             ?_assertEqual(
-                {ok, <<"4b007901b765489abead49d926f721d065a429c1">>},
+                <<"4b007901b765489abead49d926f721d065a429c1">>,
                 couch_passwords:pbkdf2(<<"password">>, <<"salt">>, 4096, 20)
             )},
 
         {"Iterations: 4096, length: 25",
             ?_assertEqual(
-                {ok, <<"3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038">>},
+                <<"3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038">>,
                 couch_passwords:pbkdf2(
                     <<"passwordPASSWORDpassword">>,
                     <<"saltSALTsaltSALTsaltSALTsaltSALTsalt">>,
@@ -46,7 +46,7 @@ pbkdf2_test_() ->
             )},
         {"Null byte",
             ?_assertEqual(
-                {ok, <<"56fa6aa75548099dcc37d7f03425e0c3">>},
+                <<"56fa6aa75548099dcc37d7f03425e0c3">>,
                 couch_passwords:pbkdf2(
                     <<"pass\0word">>,
                     <<"sa\0lt">>,
@@ -59,7 +59,7 @@ pbkdf2_test_() ->
         {timeout, 600,
             {"Iterations: 16777216 - this may take some time",
                 ?_assertEqual(
-                    {ok, <<"eefe3d61cd4da4e4e9945b3d6ba2158c2634e984">>},
+                    <<"eefe3d61cd4da4e4e9945b3d6ba2158c2634e984">>,
                     couch_passwords:pbkdf2(<<"password">>, <<"salt">>, 16777216, 20)
                 )}}
     ]}.
diff --git a/src/docs/src/intro/security.rst b/src/docs/src/intro/security.rst
index a9cbfc32d..d64cc0b44 100644
--- a/src/docs/src/intro/security.rst
+++ b/src/docs/src/intro/security.rst
@@ -288,7 +288,7 @@ several *mandatory* fields, that CouchDB needs for authentication:
 
 - **_id** (*string*): Document ID. Contains user's login with special prefix
   :ref:`org.couchdb.user`
-- **derived_key** (*string*): `PBKDF2`_ key derived from salt/iterations.
+- **derived_key** (*string*): `PBKDF2`_ key derived from prf/salt/iterations.
 - **name** (*string*): User's name aka login. **Immutable** e.g. you cannot
   rename an existing user - you have to create new one
 - **roles** (*array* of *string*): List of user roles. CouchDB doesn't provide
@@ -305,6 +305,8 @@ several *mandatory* fields, that CouchDB needs for authentication:
   ``password_scheme`` options.
 - **iterations** (*integer*): Number of iterations to derive key, used for ``pbkdf2``
   ``password_scheme`` See the :ref:`configuration API <config/chttpd_auth>`:: for details.
+- **pbkdf2_prf** (*string*): The PRF to use for ``pbkdf2``. If missing, ``sha`` is
+  assumed. Can be any of ``sha``, ``sha224``, ``sha256``, ``sha384``, ``sha512``.
 - **type** (*string*): Document type. Constantly has the value ``user``
 
 Additionally, you may specify any custom fields that relate to the target
diff --git a/test/elixir/test/config_test.exs b/test/elixir/test/config_test.exs
index d2d72cab8..ac7ec93e3 100644
--- a/test/elixir/test/config_test.exs
+++ b/test/elixir/test/config_test.exs
@@ -92,7 +92,7 @@ defmodule ConfigTest do
     assert Couch.login("administrator", plain_pass)
     hash_pass = get_config(context, "admins", "administrator")
 
-    assert Regex.match?(~r/^-pbkdf2-/, hash_pass) or
+    assert Regex.match?(~r/^-pbkdf2(:[a-z0-9]+)?-/, hash_pass) or
              Regex.match?(~r/^-hashed-/, hash_pass)
 
     delete_config(context, "admins", "administrator")


(couchdb) 08/13: Enhance dev/run to use pbkdf2 with sha256

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 30b566233d48baa33dd6a23aa399591b3a2c482a
Author: Robert Newson <rn...@apache.org>
AuthorDate: Fri Oct 27 17:29:33 2023 +0100

    Enhance dev/run to use pbkdf2 with sha256
---
 dev/run | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/dev/run b/dev/run
index 823206ae0..707c8a4d7 100755
--- a/dev/run
+++ b/dev/run
@@ -17,6 +17,7 @@ import base64
 import contextlib
 import functools
 import glob
+import hashlib
 import inspect
 import json
 import ntpath
@@ -560,7 +561,7 @@ def gen_password():
     return base64.b64encode(os.urandom(6)).decode()
 
 
-def hashify(pwd, salt=COMMON_SALT, iterations=10, keylen=20):
+def hashify(pwd, salt=COMMON_SALT, iterations=10, keylen=32):
     """
     Implements password hashing according to:
       - https://issues.apache.org/jira/browse/COUCHDB-1060
@@ -571,8 +572,8 @@ def hashify(pwd, salt=COMMON_SALT, iterations=10, keylen=20):
     >>> hashify(candeira)
     -pbkdf2-99eb34d97cdaa581e6ba7b5386e112c265c5c670,d1d2d4d8909c82c81b6c8184429a0739,10
     """
-    derived_key = pbkdf2_hex(pwd, salt, iterations, keylen)
-    return "-pbkdf2-%s,%s,%s" % (derived_key, salt, iterations)
+    derived_key = pbkdf2_hex(pwd, salt, iterations, keylen, hashfunc=hashlib.sha256)
+    return "-pbkdf2:sha256-%s,%s,%s" % (derived_key, salt, iterations)
 
 
 def startup(ctx):


(couchdb) 13/13: update Makefile.win to match

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 125eddfb22cd45500e72e29654f9fcdbcef5b1d3
Author: Robert Newson <rn...@apache.org>
AuthorDate: Fri Nov 3 18:06:39 2023 +0000

    update Makefile.win to match
---
 Makefile.win | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/Makefile.win b/Makefile.win
index 817fbccb3..133dd4a96 100644
--- a/Makefile.win
+++ b/Makefile.win
@@ -89,7 +89,7 @@ DIALYZE_OPTS=$(shell powershell -command "('apps=${apps} skip_deps=${skip_deps}
 
 EXUNIT_OPTS=$(subst $(comma),$(space),$(tests))
 
-TEST_OPTS="-c startup_jitter=0 -c default_security=admin_local"
+TEST_OPTS="-c startup_jitter=0 -c default_security=admin_local -c iterations=9"
 
 ################################################################################
 # Main commands
@@ -528,7 +528,7 @@ nouveau-test-elixir: export MIX_ENV=integration
 nouveau-test-elixir: elixir-init devclean
 nouveau-test-elixir: couch nouveau
 ifeq ($(with_nouveau), 1)
-	@dev\run -n 1 -q -a adm:pass --with-nouveau \
+	@dev\run "$(TEST_OPTS)" -n 1 -q -a adm:pass --with-nouveau \
 		--locald-config test/elixir/test/config/test-config.ini \
 		--no-eval 'mix test --trace --include test/elixir/test/config/nouveau.elixir'
 endif


(couchdb) 04/13: in-memory password hash cache

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit fe251220f56ddb107d97135a16a31a487df936fd
Author: Robert Newson <rn...@apache.org>
AuthorDate: Thu Oct 19 15:18:48 2023 +0100

    in-memory password hash cache
---
 rel/overlay/etc/default.ini             |  9 +++++
 src/couch/src/couch_passwords_cache.erl | 64 +++++++++++++++++++++++++++++++++
 src/couch/src/couch_primary_sup.erl     | 13 ++++++-
 3 files changed, 85 insertions(+), 1 deletion(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 163228035..90656b7a5 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -913,3 +913,12 @@ enable = {{with_nouveau}}
 ;background_view_indexing_threshold = 80
 ;interactive_view_indexing_threshold = 90
 ;interactive_database_writes_threshold = 90
+
+; To speed authentication on database requests when on-disk iteration count is
+; high, an in-memory cache of password hashes with a lower iteration threshold
+; is maintained.
+; If you exclusively use authentication methods other than basic authentication
+; (e.g, session cookies or proxy authentication) you might wish to disable this
+; to avoid the slight per-request cost of this hashing.
+[couch_passwords_cache]
+;enable = true
diff --git a/src/couch/src/couch_passwords_cache.erl b/src/couch/src/couch_passwords_cache.erl
new file mode 100644
index 000000000..5cde41271
--- /dev/null
+++ b/src/couch/src/couch_passwords_cache.erl
@@ -0,0 +1,64 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+%   http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+%
+% password hashes on disk can take a long time to verify. This is by design, to
+% guard against offline attacks. This module adds an in-memory cache for password
+% verification to speed up verification after a successful slow verification from
+% the hash stored on disk, in order not to transfer the deliberate offline attack
+% protection to database requests.
+%
+% In memory we record a PBKDF_SHA256 derivation using a low number of iterations
+% and check against this if present. Entries in couch_passwords_cache expire automatically
+% and the maximum number of cached entries is configurable.
+
+-module(couch_passwords_cache).
+
+-define(CACHE, couch_passwords_cache_lru).
+
+% public api
+-export([authenticate/4, insert/4]).
+
+% public functions
+-spec authenticate(
+    AuthModule :: atom(), UserName :: binary(), Password :: binary(), Salt :: binary()
+) ->
+    not_found | boolean().
+authenticate(AuthModule, UserName, Password, Salt) when
+    is_atom(AuthModule), is_binary(UserName), is_binary(Password), is_binary(Salt)
+->
+    case config:get_boolean("couch_passwords_cache", "enable", true) of
+        true ->
+            authenticate_int(AuthModule, UserName, Password, Salt);
+        false ->
+            not_found
+    end.
+
+authenticate_int(AuthModule, UserName, Password, Salt) ->
+    % salt in key to ensure entry is not a match if user changes their password
+    % (as salt is re-randomised in that event)
+    case ets_lru:lookup_d(?CACHE, {AuthModule, UserName, Salt}) of
+        not_found ->
+            not_found;
+        {ok, ExpectedHash} ->
+            ActualHash = hash(Password, Salt),
+            couch_passwords:verify(ExpectedHash, ActualHash)
+    end.
+
+-spec insert(AuthModule :: atom(), UserName :: binary(), Password :: binary(), Salt :: binary()) ->
+    ok.
+insert(AuthModule, UserName, Password, Salt) when
+    is_atom(AuthModule), is_binary(UserName), is_binary(Password), is_binary(Salt)
+->
+    ets_lru:insert(?CACHE, {AuthModule, UserName, Salt}, hash(Password, Salt)).
+
+hash(Password, Salt) ->
+    fast_pbkdf2:pbkdf2(sha256, Password, Salt, _Iterations = 5, _KeyLen = 32).
diff --git a/src/couch/src/couch_primary_sup.erl b/src/couch/src/couch_primary_sup.erl
index 1eae87160..acf753683 100644
--- a/src/couch/src/couch_primary_sup.erl
+++ b/src/couch/src/couch_primary_sup.erl
@@ -23,7 +23,18 @@ init([]) ->
             {couch_task_status, {couch_task_status, start_link, []}, permanent, brutal_kill, worker,
                 [couch_task_status]},
             {couch_password_hasher, {couch_password_hasher, start_link, []}, permanent, brutal_kill,
-                worker, [couch_password_hasher]}
+                worker, [couch_password_hasher]},
+            {couch_passwords_cache_lru,
+                {ets_lru, start_link, [
+                    couch_passwords_cache_lru,
+                    [
+                        {max_objects,
+                            config:get_integer("couch_passwords_cache", "max_objects", 10_000)},
+                        {max_lifetime,
+                            config:get_integer("couch_passwords_cache", "max_lifetime", 360_000)}
+                    ]
+                ]},
+                permanent, 5000, worker, [ets_lru]}
         ] ++ couch_servers(),
     {ok, {{one_for_one, 10, 3600}, Children}}.
 


(couchdb) 09/13: remove all password derivation fields before adding back the new ones

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 8393aaea80e6fbe9b07dc8e4dfb0eb45e06e5f67
Author: Robert Newson <rn...@apache.org>
AuthorDate: Mon Oct 23 18:32:22 2023 +0100

    remove all password derivation fields before adding back the new ones
---
 src/couch/src/couch_users_db.erl | 48 +++++++++++++++++++++++++++-------------
 1 file changed, 33 insertions(+), 15 deletions(-)

diff --git a/src/couch/src/couch_users_db.erl b/src/couch/src/couch_users_db.erl
index b4d1da938..c6b730209 100644
--- a/src/couch/src/couch_users_db.erl
+++ b/src/couch/src/couch_users_db.erl
@@ -27,7 +27,6 @@
 -define(PRESERVE_SALT, <<"preserve_salt">>).
 -define(ITERATIONS, <<"iterations">>).
 -define(SALT, <<"salt">>).
--define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})).
 -define(REQUIREMENT_ERROR, "Password does not conform to requirements.").
 -define(PASSWORD_SERVER_ERROR, "Server cannot hash passwords at this time.").
 
@@ -90,12 +89,14 @@ save_doc(#doc{body = {Body}} = Doc) ->
         {ClearPassword, "simple"} ->
             ok = validate_password(ClearPassword),
             PasswordSha = couch_passwords:simple(ClearPassword, Salt),
-            Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?SIMPLE),
-            Body1 = ?replace(Body0, ?SALT, Salt),
-            Body2 = ?replace(Body1, ?PASSWORD_SHA, PasswordSha),
-            Body3 = proplists:delete(?PRESERVE_SALT, Body2),
-            Body4 = proplists:delete(?PASSWORD, Body3),
-            Doc#doc{body = {Body4}};
+            Body0 = remove_password_fields(Body),
+            Body1 = [
+                {?PASSWORD_SCHEME, ?SIMPLE},
+                {?SALT, Salt},
+                {?PASSWORD_SHA, PasswordSha}
+                | Body0
+            ],
+            Doc#doc{body = {Body1}};
         {ClearPassword, "pbkdf2"} ->
             ok = validate_password(ClearPassword),
             PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
@@ -105,19 +106,36 @@ save_doc(#doc{body = {Body}} = Doc) ->
             DerivedKey = couch_passwords:pbkdf2(
                 list_to_existing_atom(PRF), ClearPassword, Salt, Iterations
             ),
-            Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?PBKDF2),
-            Body1 = ?replace(Body0, ?PBKDF2_PRF, ?l2b(PRF)),
-            Body2 = ?replace(Body1, ?ITERATIONS, Iterations),
-            Body3 = ?replace(Body2, ?DERIVED_KEY, DerivedKey),
-            Body4 = ?replace(Body3, ?SALT, Salt),
-            Body5 = proplists:delete(?PRESERVE_SALT, Body4),
-            Body6 = proplists:delete(?PASSWORD, Body5),
-            Doc#doc{body = {Body6}};
+            Body0 = remove_password_fields(Body),
+            Body1 = [
+                {?PASSWORD_SCHEME, ?PBKDF2},
+                {?PBKDF2_PRF, ?l2b(PRF)},
+                {?SALT, Salt},
+                {?ITERATIONS, Iterations},
+                {?DERIVED_KEY, DerivedKey}
+                | Body0
+            ],
+            Doc#doc{body = {Body1}};
         {_ClearPassword, Scheme} ->
             couch_log:error("[couch_httpd_auth] password_scheme value of '~p' is invalid.", [Scheme]),
             throw({forbidden, ?PASSWORD_SERVER_ERROR})
     end.
 
+remove_password_fields(Props) ->
+    lists:filter(fun not_password_field/1, Props).
+
+not_password_field({Key, _Value}) ->
+    not lists:member(Key, [
+        ?DERIVED_KEY,
+        ?ITERATIONS,
+        ?PASSWORD,
+        ?PASSWORD_SCHEME,
+        ?PASSWORD_SHA,
+        ?PRESERVE_SALT,
+        ?PBKDF2_PRF,
+        ?SALT
+    ]).
+
 % Validate if a new password matches all RegExp in the password_regexp setting.
 % Throws if not.
 % In this function the [couch_httpd_auth] password_regexp config is parsed.


(couchdb) 10/13: improve assertion failure messages

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 5fa15c5711f4a75993fb3884c4649c80ee507c4e
Author: Robert Newson <rn...@apache.org>
AuthorDate: Tue Oct 24 09:15:44 2023 +0100

    improve assertion failure messages
---
 src/chttpd/test/eunit/chttpd_auth_hash_algorithms_tests.erl | 4 ++--
 src/chttpd/test/eunit/chttpd_auth_tests.erl                 | 5 +++--
 2 files changed, 5 insertions(+), 4 deletions(-)

diff --git a/src/chttpd/test/eunit/chttpd_auth_hash_algorithms_tests.erl b/src/chttpd/test/eunit/chttpd_auth_hash_algorithms_tests.erl
index 52f4f9e8b..3085079ad 100644
--- a/src/chttpd/test/eunit/chttpd_auth_hash_algorithms_tests.erl
+++ b/src/chttpd/test/eunit/chttpd_auth_hash_algorithms_tests.erl
@@ -88,8 +88,8 @@ test_hash_algorithm([DefaultHashAlgorithm | DecodingHashAlgorithmsList] = _, Sta
         get_full_secret(?ADM_USER),
         CurrentTime
     ),
-    {ok, ReqStatus, _, _} = test_request:request(get, base_url(), [{cookie, Cookie}]),
-    ?assertEqual(Status, ReqStatus),
+    {ok, ReqStatus, Hdrs, Body} = test_request:request(get, base_url(), [{cookie, Cookie}]),
+    ?assertMatch({Status, _, _}, {ReqStatus, Hdrs, Body}),
     test_hash_algorithm(DecodingHashAlgorithmsList, Status).
 
 test_hash_algorithms_should_work({_, {WorkingHashes, _, SupportedHashAlgorithms}} = _) ->
diff --git a/src/chttpd/test/eunit/chttpd_auth_tests.erl b/src/chttpd/test/eunit/chttpd_auth_tests.erl
index 7e7b94a12..93ddb8cc6 100644
--- a/src/chttpd/test/eunit/chttpd_auth_tests.erl
+++ b/src/chttpd/test/eunit/chttpd_auth_tests.erl
@@ -194,9 +194,10 @@ test_hash_algorithm([DefaultHashAlgorithm | DecodingHashAlgorithmsList] = _) ->
         {"X-Auth-CouchDB-Roles", "PROXY-USER-ROLE1, PROXY-USER-ROLE2"},
         {"X-Auth-CouchDB-Token", Token}
     ],
-    {ok, _, _, ReqBody} = test_request:get(base_url() ++ "/_session", Headers),
+    {ok, RespStatus, _, RespBody} = test_request:get(base_url() ++ "/_session", Headers),
+    ?assertMatch({200, _}, {RespStatus, RespBody}),
     IsAuthenticatedViaProxy = couch_util:get_nested_json_value(
-        jiffy:decode(ReqBody), [<<"info">>, <<"authenticated">>]
+        jiffy:decode(RespBody), [<<"info">>, <<"authenticated">>]
     ),
     ?assertEqual(IsAuthenticatedViaProxy, <<"proxy">>),
     test_hash_algorithm(DecodingHashAlgorithmsList).


(couchdb) 07/13: upgrade password on next session or basic auth

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 90d8063128dd5fc66fd0d0821647491fbc6d6cc2
Author: Robert Newson <rn...@apache.org>
AuthorDate: Fri Oct 27 17:30:17 2023 +0100

    upgrade password on next session or basic auth
---
 rel/overlay/etc/default.ini             |  1 +
 src/chttpd/src/chttpd_auth_cache.erl    |  2 +-
 src/couch/src/couch_auth_cache.erl      |  2 +-
 src/couch/src/couch_httpd_auth.erl      |  6 +++
 src/couch/src/couch_password_hasher.erl | 87 +++++++++++++++++++++++++++++++--
 src/couch/src/couch_users_db.erl        | 28 ++++++++---
 6 files changed, 115 insertions(+), 11 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 90656b7a5..7a4417856 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -326,6 +326,7 @@ bind_address = 127.0.0.1
 ;max_iterations = 1000000000
 ;password_scheme = pbkdf2
 ;pbkdf2_prf = sha256 ; must be one of sha | sha224 | sha256 | sha384 | sha512
+;upgrade_hash_on_auth = true; whether to upgrade password hashes on successful authentication.
 
 ; List of Erlang RegExp or tuples of RegExp and an optional error message.
 ; Where a new password must match all RegExp.
diff --git a/src/chttpd/src/chttpd_auth_cache.erl b/src/chttpd/src/chttpd_auth_cache.erl
index 36157ed38..c664cd3a9 100644
--- a/src/chttpd/src/chttpd_auth_cache.erl
+++ b/src/chttpd/src/chttpd_auth_cache.erl
@@ -61,7 +61,7 @@ get_user_creds(_Req, UserName) when is_binary(UserName) ->
 
 update_user_creds(_Req, UserDoc, _Ctx) ->
     {_, Ref} = spawn_monitor(fun() ->
-        case fabric:update_doc(dbname(), UserDoc, []) of
+        case fabric:update_doc(dbname(), UserDoc, [?ADMIN_CTX]) of
             {ok, _} ->
                 exit(ok);
             Else ->
diff --git a/src/couch/src/couch_auth_cache.erl b/src/couch/src/couch_auth_cache.erl
index efa8e4765..08675c941 100644
--- a/src/couch/src/couch_auth_cache.erl
+++ b/src/couch/src/couch_auth_cache.erl
@@ -54,7 +54,7 @@ get_user_creds(_Req, UserName) ->
 update_user_creds(_Req, UserDoc, _AuthCtx) ->
     ok = ensure_users_db_exists(),
     couch_util:with_db(users_db(), fun(UserDb) ->
-        {ok, _NewRev} = couch_db:update_doc(UserDb, UserDoc, []),
+        {ok, _NewRev} = couch_db:update_doc(UserDb, UserDoc, [?ADMIN_CTX]),
         ok
     end).
 
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index c0a823a12..58fd4320c 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -113,6 +113,9 @@ default_authentication_handler(Req, AuthModule) ->
                     Password = ?l2b(Pass),
                     case authenticate(AuthModule, UserName, Password, UserProps) of
                         true ->
+                            couch_password_hasher:maybe_upgrade_password_hash(
+                                AuthModule, UserName, Password, UserProps
+                            ),
                             Req#httpd{
                                 user_ctx = #user_ctx{
                                     name = UserName,
@@ -500,6 +503,9 @@ handle_session_req(#httpd{method = 'POST', mochi_req = MochiReq} = Req, AuthModu
     case authenticate(AuthModule, UserName, Password, UserProps) of
         true ->
             verify_totp(UserProps, Form),
+            couch_password_hasher:maybe_upgrade_password_hash(
+                AuthModule, UserName, Password, UserProps
+            ),
             % setup the session cookie
             Secret = ?l2b(ensure_cookie_auth_secret()),
             UserSalt = couch_util:get_value(<<"salt">>, UserProps),
diff --git a/src/couch/src/couch_password_hasher.erl b/src/couch/src/couch_password_hasher.erl
index b34bc52d9..197fcd4fc 100644
--- a/src/couch/src/couch_password_hasher.erl
+++ b/src/couch/src/couch_password_hasher.erl
@@ -21,17 +21,38 @@
     init/1,
     handle_call/3,
     handle_cast/2,
+    handle_info/2,
     code_change/3
 ]).
 
--export([hash_admin_passwords/1]).
+-export([maybe_upgrade_password_hash/4, hash_admin_passwords/1]).
 
--record(state, {}).
+-export([worker_loop/1]).
+
+-define(IN_PROGRESS_ETS, couch_password_hasher_in_progress).
+
+-record(state, {
+    worker_pid
+}).
 
 %%%===================================================================
 %%% Public functions
 %%%===================================================================
 
+maybe_upgrade_password_hash(AuthModule, UserName, Password, UserProps) ->
+    UpgradeEnabled = config:get_boolean("chttpd_auth", "upgrade_hash_on_auth", true),
+    IsAdmin = is_admin(UserProps),
+    NeedsUpgrade = needs_upgrade(UserProps),
+    InProgress = in_progress(AuthModule, UserName),
+    if
+        UpgradeEnabled andalso not IsAdmin andalso NeedsUpgrade andalso not InProgress ->
+            gen_server:cast(
+                ?MODULE, {upgrade_password_hash, AuthModule, UserName, Password, UserProps}
+            );
+        true ->
+            ok
+    end.
+
 -spec hash_admin_passwords(Persist :: boolean()) -> Reply :: term().
 hash_admin_passwords(Persist) ->
     gen_server:cast(?MODULE, {hash_admin_passwords, Persist}).
@@ -45,17 +66,34 @@ start_link() ->
 
 init(_Args) ->
     hash_admin_passwords(true),
-    {ok, #state{}}.
+    ?IN_PROGRESS_ETS = ets:new(?IN_PROGRESS_ETS, [named_table, {read_concurrency, true}]),
+    spawn_link(?MODULE, worker_loop, [self()]),
+    {ok, start_worker_loop(#state{})}.
 
 handle_call(Msg, _From, #state{} = State) ->
     {stop, {invalid_call, Msg}, {invalid_call, Msg}, State}.
 
+handle_cast({upgrade_password_hash, AuthModule, UserName, Password, UserProps}, State) ->
+    case ets:insert_new(?IN_PROGRESS_ETS, {{AuthModule, UserName}}) of
+        true ->
+            State#state.worker_pid !
+                {upgrade_password_hash, AuthModule, UserName, Password, UserProps};
+        false ->
+            ok
+    end,
+    {noreply, State};
 handle_cast({hash_admin_passwords, Persist}, State) ->
     hash_admin_passwords_int(Persist),
     {noreply, State};
 handle_cast(Msg, State) ->
     {stop, {invalid_cast, Msg}, State}.
 
+handle_info({done, AuthModule, UserName}, State) ->
+    ets:delete(?IN_PROGRESS_ETS, {AuthModule, UserName}),
+    {noreply, State};
+handle_info(Msg, State) ->
+    {stop, {invalid_info, Msg}, State}.
+
 code_change(_OldVsn, #state{} = State, _Extra) ->
     {ok, State}.
 
@@ -71,3 +109,46 @@ hash_admin_passwords_int(Persist) ->
         end,
         couch_passwords:get_unhashed_admins()
     ).
+
+is_admin(UserProps) ->
+    Roles = couch_util:get_value(<<"roles">>, UserProps, []),
+    lists:member(<<"_admin">>, Roles).
+
+needs_upgrade(UserProps) ->
+    CurrentScheme = couch_util:get_value(<<"password_scheme">>, UserProps),
+    TargetScheme = ?l2b(chttpd_util:get_chttpd_auth_config("password_scheme", "pbkdf2")),
+    CurrentPRF = couch_util:get_value(<<"pbkdf2_prf">>, UserProps),
+    TargetPRF = ?l2b(chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256")),
+    CurrentIterations = couch_util:get_value(<<"iterations">>, UserProps),
+    TargetIterations = chttpd_util:get_chttpd_auth_config_integer(
+        "iterations", 50000
+    ),
+    case {TargetScheme, TargetIterations, TargetPRF} of
+        {CurrentScheme, CurrentIterations, _} when CurrentScheme == <<"simple">> ->
+            false;
+        {CurrentScheme, CurrentIterations, CurrentPRF} when CurrentScheme == <<"pbkdf2">> ->
+            false;
+        {_, _, _} ->
+            true
+    end.
+
+in_progress(AuthModule, UserName) ->
+    ets:member(?IN_PROGRESS_ETS, {AuthModule, UserName}).
+
+start_worker_loop(State) ->
+    WorkerPid = spawn_link(?MODULE, worker_loop, [self()]),
+    State#state{worker_pid = WorkerPid}.
+
+worker_loop(Parent) ->
+    receive
+        {upgrade_password_hash, AuthModule, UserName, Password, UserProps} ->
+            couch_log:notice("upgrading stored password hash for '~s'", [UserName]),
+            upgrade_password_hash(AuthModule, Password, UserProps),
+            erlang:send_after(5000, Parent, {done, AuthModule, UserName})
+    end,
+    worker_loop(Parent).
+
+upgrade_password_hash(AuthModule, Password, UserProps0) ->
+    UserProps1 = [{<<"password">>, Password}, {<<"preserve_salt">>, true} | UserProps0],
+    NewUserDoc = couch_doc:from_json_obj({UserProps1}),
+    AuthModule:update_user_creds(nil, NewUserDoc, ?ADMIN_CTX).
diff --git a/src/couch/src/couch_users_db.erl b/src/couch/src/couch_users_db.erl
index f8d56882a..b4d1da938 100644
--- a/src/couch/src/couch_users_db.erl
+++ b/src/couch/src/couch_users_db.erl
@@ -24,6 +24,7 @@
 -define(PASSWORD_SHA, <<"password_sha">>).
 -define(PBKDF2, <<"pbkdf2">>).
 -define(PBKDF2_PRF, <<"pbkdf2_prf">>).
+-define(PRESERVE_SALT, <<"preserve_salt">>).
 -define(ITERATIONS, <<"iterations">>).
 -define(SALT, <<"salt">>).
 -define(replace(L, K, V), lists:keystore(K, 1, L, {K, V})).
@@ -64,6 +65,21 @@ before_doc_update(Doc, Db, _UpdateType) ->
 save_doc(#doc{body = {Body}} = Doc) ->
     %% Support both schemes to smooth migration from legacy scheme
     Scheme = chttpd_util:get_chttpd_auth_config("password_scheme", "pbkdf2"),
+
+    % We preserve the salt value if requested (for a hashing upgrade, typically)
+    % in order to avoid conflicts if multiple nodes try to upgrade at the same time
+    % and to avoid invalidating existing session cookies (since the password did not
+    % change).
+    PreserveSalt = couch_util:get_value(?PRESERVE_SALT, Body, false),
+    Salt =
+        case PreserveSalt of
+            true ->
+                % use existing salt, if present.
+                couch_util:get_value(?SALT, Body, couch_uuids:random());
+            false ->
+                couch_uuids:random()
+        end,
+
     case {couch_util:get_value(?PASSWORD, Body), Scheme} of
         % server admins don't have a user-db password entry
         {null, _} ->
@@ -73,20 +89,19 @@ save_doc(#doc{body = {Body}} = Doc) ->
         % deprecated
         {ClearPassword, "simple"} ->
             ok = validate_password(ClearPassword),
-            Salt = couch_uuids:random(),
             PasswordSha = couch_passwords:simple(ClearPassword, Salt),
             Body0 = ?replace(Body, ?PASSWORD_SCHEME, ?SIMPLE),
             Body1 = ?replace(Body0, ?SALT, Salt),
             Body2 = ?replace(Body1, ?PASSWORD_SHA, PasswordSha),
-            Body3 = proplists:delete(?PASSWORD, Body2),
-            Doc#doc{body = {Body3}};
+            Body3 = proplists:delete(?PRESERVE_SALT, Body2),
+            Body4 = proplists:delete(?PASSWORD, Body3),
+            Doc#doc{body = {Body4}};
         {ClearPassword, "pbkdf2"} ->
             ok = validate_password(ClearPassword),
             PRF = chttpd_util:get_chttpd_auth_config("pbkdf2_prf", "sha256"),
             Iterations = chttpd_util:get_chttpd_auth_config_integer(
                 "iterations", 10
             ),
-            Salt = couch_uuids:random(),
             DerivedKey = couch_passwords:pbkdf2(
                 list_to_existing_atom(PRF), ClearPassword, Salt, Iterations
             ),
@@ -95,8 +110,9 @@ save_doc(#doc{body = {Body}} = Doc) ->
             Body2 = ?replace(Body1, ?ITERATIONS, Iterations),
             Body3 = ?replace(Body2, ?DERIVED_KEY, DerivedKey),
             Body4 = ?replace(Body3, ?SALT, Salt),
-            Body5 = proplists:delete(?PASSWORD, Body4),
-            Doc#doc{body = {Body5}};
+            Body5 = proplists:delete(?PRESERVE_SALT, Body4),
+            Body6 = proplists:delete(?PASSWORD, Body5),
+            Doc#doc{body = {Body6}};
         {_ClearPassword, Scheme} ->
             couch_log:error("[couch_httpd_auth] password_scheme value of '~p' is invalid.", [Scheme]),
             throw({forbidden, ?PASSWORD_SERVER_ERROR})


(couchdb) 05/13: use couch password cache

Posted by rn...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

rnewson pushed a commit to branch decouple_offline_hash_strength_from_online
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 7b52ef56d271d39a2dff86d269255c74377c568a
Author: Robert Newson <rn...@apache.org>
AuthorDate: Thu Oct 19 19:48:53 2023 +0100

    use couch password cache
---
 src/couch/src/couch_httpd_auth.erl | 22 ++++++++++++++++++----
 1 file changed, 18 insertions(+), 4 deletions(-)

diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 9d9afe5df..c0a823a12 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -28,7 +28,7 @@
 -export([cookie_auth_header/2]).
 -export([handle_session_req/1, handle_session_req/2]).
 
--export([authenticate/2, verify_totp/2]).
+-export([authenticate/4, verify_totp/2]).
 -export([ensure_cookie_auth_secret/0, make_cookie_time/0]).
 -export([maybe_value/3]).
 
@@ -111,7 +111,7 @@ default_authentication_handler(Req, AuthModule) ->
                     reject_if_totp(UserProps),
                     UserName = ?l2b(User),
                     Password = ?l2b(Pass),
-                    case authenticate(Password, UserProps) of
+                    case authenticate(AuthModule, UserName, Password, UserProps) of
                         true ->
                             Req#httpd{
                                 user_ctx = #user_ctx{
@@ -497,7 +497,7 @@ handle_session_req(#httpd{method = 'POST', mochi_req = MochiReq} = Req, AuthModu
             nil -> {ok, [], nil};
             Result -> Result
         end,
-    case authenticate(Password, UserProps) of
+    case authenticate(AuthModule, UserName, Password, UserProps) of
         true ->
             verify_totp(UserProps, Form),
             % setup the session cookie
@@ -613,8 +613,22 @@ extract_username(Form) ->
 maybe_value(_Key, undefined, _Fun) -> [];
 maybe_value(Key, Else, Fun) -> [{Key, Fun(Else)}].
 
-authenticate(Pass, UserProps) ->
+authenticate(AuthModule, UserName, Password, UserProps) ->
     UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<>>),
+    case couch_passwords_cache:authenticate(AuthModule, UserName, Password, UserSalt) of
+        not_found ->
+            case authenticate_int(Password, UserSalt, UserProps) of
+                false ->
+                    false;
+                true ->
+                    couch_passwords_cache:insert(AuthModule, UserName, Password, UserSalt),
+                    true
+            end;
+        Result when is_boolean(Result) ->
+            Result
+    end.
+
+authenticate_int(Pass, UserSalt, UserProps) ->
     {PasswordHash, ExpectedHash} =
         case couch_util:get_value(<<"password_scheme">>, UserProps, <<"simple">>) of
             <<"simple">> ->