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

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

commit b16c700f44354e9f7157903d57e8b53061b1a25e
Author: Robert Newson <[email protected]>
AuthorDate: Fri Nov 14 16:05:24 2025 +0000

    Support safe secret rotation
---
 src/couch/src/couch_httpd_auth.erl           |  69 +++++-----
 src/couch/src/couch_secondary_sup.erl        |   1 +
 src/couch/src/couch_secrets.erl              | 183 +++++++++++++++++++++++++++
 src/couch/test/eunit/couch_secrets_tests.erl |  81 ++++++++++++
 src/docs/src/config/auth.rst                 |  12 ++
 5 files changed, 315 insertions(+), 31 deletions(-)

diff --git a/src/couch/src/couch_httpd_auth.erl 
b/src/couch/src/couch_httpd_auth.erl
index 7c6a60d2b..604c9dcfe 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -209,14 +209,15 @@ proxy_auth_user(Req) ->
                     case chttpd_util:get_chttpd_auth_config("secret") of
                         undefined ->
                             Req#httpd{user_ctx = #user_ctx{name = 
?l2b(UserName), roles = Roles}};
-                        Secret ->
-                            HashAlgorithms = 
couch_util:get_config_hash_algorithms(),
-                            Token = header_value(Req, XHeaderToken),
-                            VerifyTokens = fun(HashAlg) ->
-                                Hmac = couch_util:hmac(HashAlg, Secret, 
UserName),
-                                
couch_passwords:verify(couch_util:to_hex(Hmac), Token)
-                            end,
-                            case lists:any(VerifyTokens, HashAlgorithms) of
+                        _Secret ->
+                            Token =
+                                try
+                                    binary:decode_hex(?l2b(header_value(Req, 
XHeaderToken)))
+                                catch
+                                    error:badarg ->
+                                        undefined
+                                end,
+                            case couch_secrets:verify(UserName, Token) of
                                 true ->
                                     Req#httpd{
                                         user_ctx = #user_ctx{
@@ -355,35 +356,30 @@ cookie_authentication_handler(#httpd{mochi_req = 
MochiReq} = Req, AuthModule) ->
                 end,
             % Verify expiry and hash
             CurrentTime = make_cookie_time(),
-            HashAlgorithms = couch_util:get_config_hash_algorithms(),
             case chttpd_util:get_chttpd_auth_config("secret") of
                 undefined ->
                     couch_log:debug("cookie auth secret is not set", []),
                     Req;
-                SecretStr ->
-                    Secret = ?l2b(SecretStr),
+                _SecretStr ->
                     case AuthModule:get_user_creds(Req, User) of
                         nil ->
                             Req;
                         {ok, UserProps, _AuthCtx} ->
                             UserSalt = couch_util:get_value(<<"salt">>, 
UserProps, <<"">>),
-                            FullSecret = <<Secret/binary, UserSalt/binary>>,
                             Hash = ?l2b(HashStr),
-                            VerifyHash = fun(HashAlg) ->
-                                Hmac = couch_util:hmac(
-                                    HashAlg,
-                                    FullSecret,
-                                    lists:join(":", [User, TimeStr])
-                                ),
-                                couch_passwords:verify(Hmac, Hash)
-                            end,
                             Timeout = 
chttpd_util:get_chttpd_auth_config_integer(
                                 "timeout", 600
                             ),
                             couch_log:debug("timeout ~p", [Timeout]),
                             case (catch list_to_integer(TimeStr, 16)) of
                                 TimeStamp when CurrentTime < TimeStamp + 
Timeout ->
-                                    case lists:any(VerifyHash, HashAlgorithms) 
of
+                                    case
+                                        couch_secrets:verify(
+                                            lists:join(":", [User, TimeStr]),
+                                            UserSalt,
+                                            Hash
+                                        )
+                                    of
                                         true ->
                                             TimeLeft = TimeStamp + Timeout - 
CurrentTime,
                                             couch_log:debug(
@@ -398,7 +394,7 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} 
= Req, AuthModule) ->
                                                     )
                                                 },
                                                 auth =
-                                                    {FullSecret, TimeLeft < 
Timeout * 0.9}
+                                                    {UserSalt, TimeLeft < 
Timeout * 0.9}
                                             };
                                         _Else ->
                                             Req
@@ -413,7 +409,7 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} 
= Req, AuthModule) ->
 cookie_auth_header(#httpd{user_ctx = #user_ctx{name = null}}, _Headers) ->
     [];
 cookie_auth_header(
-    #httpd{user_ctx = #user_ctx{name = User}, auth = {Secret, _SendCookie = 
true}} =
+    #httpd{user_ctx = #user_ctx{name = User}, auth = {UserSalt, _SendCookie = 
true}} =
         Req,
     Headers
 ) ->
@@ -430,21 +426,20 @@ cookie_auth_header(
     if
         AuthSession == undefined ->
             TimeStamp = make_cookie_time(),
-            [cookie_auth_cookie(Req, User, Secret, TimeStamp)];
+            [cookie_auth_cookie(Req, User, UserSalt, TimeStamp)];
         true ->
             []
     end;
 cookie_auth_header(_Req, _Headers) ->
     [].
 
-cookie_auth_cookie(Req, User, Secret, TimeStamp) ->
+cookie_auth_cookie(Req, User, UserSalt, TimeStamp) ->
     SessionItems = [User, integer_to_list(TimeStamp, 16)],
-    cookie_auth_cookie(Req, Secret, SessionItems).
+    cookie_auth_cookie(Req, UserSalt, SessionItems).
 
-cookie_auth_cookie(Req, Secret, SessionItems) when is_list(SessionItems) ->
+cookie_auth_cookie(Req, UserSalt, SessionItems) when is_list(SessionItems) ->
     SessionData = lists:join(":", SessionItems),
-    [HashAlgorithm | _] = couch_util:get_config_hash_algorithms(),
-    Hash = couch_util:hmac(HashAlgorithm, Secret, SessionData),
+    Hash = couch_secrets:sign(SessionData, UserSalt),
     mochiweb_cookies:cookie(
         "AuthSession",
         couch_util:encodeBase64Url(lists:join(":", [SessionData, Hash])),
@@ -465,11 +460,23 @@ ensure_cookie_auth_secret() ->
         undefined ->
             NewSecret = ?b2l(couch_uuids:random()),
             config:set("chttpd_auth", "secret", NewSecret),
+            wait_for_secret(10),
             NewSecret;
         Secret ->
             Secret
     end.
 
+wait_for_secret(0) ->
+    ok;
+wait_for_secret(N) ->
+    case couch_secrets:secret_is_set() of
+        true ->
+            ok;
+        false ->
+            timer:sleep(50),
+            wait_for_secret(N - 1)
+    end.
+
 % session handlers
 % Login handler with user db
 handle_session_req(Req) ->
@@ -514,11 +521,11 @@ handle_session_req(#httpd{method = 'POST', mochi_req = 
MochiReq} = Req, AuthModu
                 Req, UserName, Password, UserProps, AuthModule, AuthCtx
             ),
             % setup the session cookie
-            Secret = ?l2b(ensure_cookie_auth_secret()),
+            ensure_cookie_auth_secret(),
             UserSalt = couch_util:get_value(<<"salt">>, UserProps),
             CurrentTime = make_cookie_time(),
             Cookie = cookie_auth_cookie(
-                Req, UserName, <<Secret/binary, UserSalt/binary>>, CurrentTime
+                Req, UserName, UserSalt, CurrentTime
             ),
             % TODO document the "next" feature in Futon
             {Code, Headers} =
diff --git a/src/couch/src/couch_secondary_sup.erl 
b/src/couch/src/couch_secondary_sup.erl
index 8fc3c9a13..766235d5d 100644
--- a/src/couch/src/couch_secondary_sup.erl
+++ b/src/couch/src/couch_secondary_sup.erl
@@ -24,6 +24,7 @@ init([]) ->
     ],
     Daemons =
         [
+            {couch_secrets, {couch_secrets, start_link, []}},
             {query_servers, {couch_proc_manager, start_link, []}},
             {vhosts, {couch_httpd_vhost, start_link, []}},
             {uuids, {couch_uuids, start, []}},
diff --git a/src/couch/src/couch_secrets.erl b/src/couch/src/couch_secrets.erl
new file mode 100644
index 000000000..df40643e3
--- /dev/null
+++ b/src/couch/src/couch_secrets.erl
@@ -0,0 +1,183 @@
+% 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.
+
+-module(couch_secrets).
+
+-behaviour(gen_server).
+-behaviour(config_listener).
+
+-include_lib("couch/include/couch_db.hrl").
+
+%% public api
+-export([sign/1, sign/2, verify/2, verify/3, secret_is_set/0]).
+
+%% gen_server functions
+-export([
+    start_link/0,
+    init/1,
+    handle_call/3,
+    handle_cast/2,
+    handle_continue/2,
+    handle_info/2
+]).
+
+%% config_listener functions
+-export([
+    handle_config_change/5,
+    handle_config_terminate/3
+]).
+
+sign(Message) ->
+    sign(Message, <<>>).
+
+sign(Message, ExtraSecret) ->
+    [HashAlgorithm | _] = couch_util:get_config_hash_algorithms(),
+    case current_secret_from_ets() of
+        undefined ->
+            throw({internal_server_error, <<"cookie auth secret is not 
set">>});
+        CurrentSecret ->
+            FullSecret = <<CurrentSecret/binary, ExtraSecret/binary>>,
+            couch_util:hmac(HashAlgorithm, FullSecret, Message)
+    end.
+
+verify(Message, ExpectedMAC) ->
+    verify(Message, <<>>, ExpectedMAC).
+
+verify(Message, ExtraSecret, ExpectedMAC) ->
+    FullSecrets = [<<Secret/binary, ExtraSecret/binary>> || Secret <- 
all_secrets_from_ets()],
+    AllAlgorithms = couch_util:get_config_hash_algorithms(),
+    verify(Message, AllAlgorithms, FullSecrets, ExpectedMAC).
+
+verify(Message, AllAlgorithms, FullSecrets, ExpectedMAC) ->
+    Algorithms = lists:filter(
+        fun(Algorithm) ->
+            #{size := Size} = crypto:hash_info(Algorithm),
+            Size == byte_size(ExpectedMAC)
+        end,
+        AllAlgorithms
+    ),
+    VerifyFun = fun({Secret, Algorithm}) ->
+        ActualMAC = couch_util:hmac(Algorithm, Secret, Message),
+        crypto:hash_equals(ExpectedMAC, ActualMAC)
+    end,
+    lists:any(VerifyFun, [{S, A} || S <- FullSecrets, A <- Algorithms]).
+
+secret_is_set() ->
+    current_secret_from_ets() /= undefined.
+
+start_link() ->
+    gen_server:start_link({local, ?MODULE}, ?MODULE, nil, []).
+
+init(nil) ->
+    ets:new(?MODULE, [named_table, {read_concurrency, true}]),
+    true = ets:insert(?MODULE, {{node(), current}, 
current_secret_from_config()}),
+    update_all_secrets(),
+    erlang:send_after(5000, self(), cache_cleanup),
+    ok = config:listen_for_changes(?MODULE, undefined),
+    {ok, nil, {continue, get_secrets}}.
+
+handle_call({insert, {Node, current}, Secret}, _From, State) when Node == 
node() ->
+    OldSecret = current_secret_from_ets(),
+    TimeoutSecs = chttpd_util:get_chttpd_auth_config_integer("timeout", 600),
+    ExpiresAt = erlang:system_time(second) + TimeoutSecs,
+    ets:insert(?MODULE, [{{Node, current}, Secret}, {{Node, ExpiresAt}, 
OldSecret}]),
+    update_all_secrets(),
+    {reply, ok, State};
+handle_call({insert, Key, Secret}, _From, State) ->
+    ets:insert(?MODULE, {Key, Secret}),
+    update_all_secrets(),
+    {reply, ok, State};
+handle_call(get_secrets, _From, State) ->
+    Secrets = ets:match_object(?MODULE, {{node(), '_'}, '_'}),
+    {reply, Secrets, State};
+handle_call(flush_cache, _From, State) ->
+    %% used from tests to prevent spurious failures due to timing
+    MatchSpec = [{{{'_', '$1'}, '_'}, [{is_integer, '$1'}], [true]}],
+    NumDeleted = ets:select_delete(?MODULE, MatchSpec),
+    if
+        NumDeleted > 0 -> update_all_secrets();
+        true -> ok
+    end,
+    {reply, NumDeleted, State};
+handle_call(_Msg, _From, State) ->
+    {noreply, State}.
+
+handle_cast(_Msg, State) ->
+    {noreply, State}.
+
+handle_continue(get_secrets, State) ->
+    {Secrets, _BadNodes} = gen_server:multi_call(nodes(), ?MODULE, 
get_secrets),
+    true = ets:insert(?MODULE, Secrets),
+    update_all_secrets(),
+    {noreply, State}.
+
+handle_info(restart_config_listener, State) ->
+    ok = config:listen_for_changes(?MODULE, nil),
+    update_current_secret(),
+    {noreply, State};
+handle_info(cache_cleanup, State) ->
+    erlang:send_after(5000, self(), cache_cleanup),
+    Now = os:system_time(second),
+    MatchSpec = [{{{'_', '$1'}, '_'}, [{is_integer, '$1'}, {'<', '$1', Now}], 
[true]}],
+    NumDeleted = ets:select_delete(?MODULE, MatchSpec),
+    if
+        NumDeleted > 0 -> update_all_secrets();
+        true -> ok
+    end,
+    {noreply, State};
+handle_info(_Msg, State) ->
+    {noreply, State}.
+
+handle_config_change("chttpd_auth", "secret", _, _, _) ->
+    update_current_secret(),
+    {ok, undefined};
+handle_config_change("couch_httpd_auth", "secret", _, _, _) ->
+    update_current_secret(),
+    {ok, undefined};
+handle_config_change(_, _, _, _, _) ->
+    {ok, undefined}.
+
+handle_config_terminate(_, stop, _) ->
+    ok;
+handle_config_terminate(_Server, _Reason, _State) ->
+    erlang:send_after(3000, whereis(?MODULE), restart_config_listener).
+
+%% private functions
+
+update_current_secret() ->
+    NewSecret = current_secret_from_config(),
+    spawn(fun() ->
+        gen_server:multi_call(nodes(), ?MODULE, {insert, {node(), current}, 
NewSecret}),
+        gen_server:call(?MODULE, {insert, {node(), current}, NewSecret})
+    end).
+
+update_all_secrets() ->
+    AllSecrets = ets:match_object(?MODULE, {{'_', '_'}, '_'}),
+    ets:insert(?MODULE, {all_secrets, lists:usort([V || {_K, V} <- AllSecrets, 
is_binary(V)])}).
+
+current_secret_from_config() ->
+    case chttpd_util:get_chttpd_auth_config("secret") of
+        undefined ->
+            undefined;
+        Secret ->
+            ?l2b(Secret)
+    end.
+
+current_secret_from_ets() ->
+    secret_from_ets({node(), current}).
+
+all_secrets_from_ets() ->
+    secret_from_ets(all_secrets).
+
+secret_from_ets(Key) ->
+    [{Key, Value}] = ets:lookup(?MODULE, Key),
+    Value.
diff --git a/src/couch/test/eunit/couch_secrets_tests.erl 
b/src/couch/test/eunit/couch_secrets_tests.erl
new file mode 100644
index 000000000..37305a63b
--- /dev/null
+++ b/src/couch/test/eunit/couch_secrets_tests.erl
@@ -0,0 +1,81 @@
+% 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.
+
+-module(couch_secrets_tests).
+
+-include_lib("couch/include/couch_eunit.hrl").
+
+-define(DATA1, <<"data1">>).
+-define(DATA2, <<"data2">>).
+-define(BADMAC, <<"badmac">>).
+-define(EXTRA1, <<"extra1">>).
+-define(EXTRA2, <<"extra2">>).
+-define(SECRET1, "secret1").
+-define(SECRET2, "secret2").
+
+couch_secrets_test_() ->
+    {setup, fun test_util:start_couch/0, fun test_util:stop_couch/1,
+        {with, [
+            fun error_if_no_secret/1,
+            fun verify_works/1,
+            fun verify_extra_secret_works/1,
+            fun verify_old_secret_works/1,
+            fun verify_old_secret_stops_working/1
+        ]}}.
+
+error_if_no_secret(_Ctx) ->
+    delete_secret(),
+    ?assertThrow(
+        {internal_server_error, <<"cookie auth secret is not set">>}, 
couch_secrets:sign(?DATA1)
+    ).
+
+verify_works(_Ctx) ->
+    set_secret(?SECRET1),
+    MAC = couch_secrets:sign(?DATA1),
+    ?assert(couch_secrets:verify(?DATA1, MAC)),
+    ?assertNot(couch_secrets:verify(?DATA1, ?BADMAC)),
+    ?assertNot(couch_secrets:verify(?DATA2, MAC)).
+
+verify_extra_secret_works(_Ctx) ->
+    set_secret(?SECRET1),
+    MAC = couch_secrets:sign(?DATA1, ?EXTRA1),
+    ?assert(couch_secrets:verify(?DATA1, ?EXTRA1, MAC)),
+    ?assertNot(couch_secrets:verify(?DATA1, ?EXTRA2, MAC)),
+    ?assertNot(couch_secrets:verify(?DATA1, ?EXTRA1, ?BADMAC)),
+    ?assertNot(couch_secrets:verify(?DATA2, ?EXTRA1, MAC)).
+
+verify_old_secret_works(_Ctx) ->
+    set_secret(?SECRET1),
+    MAC1 = couch_secrets:sign(?DATA1),
+    set_secret(?SECRET2),
+    MAC2 = couch_secrets:sign(?DATA1),
+    ?assert(couch_secrets:verify(?DATA1, MAC1)),
+    ?assert(couch_secrets:verify(?DATA1, MAC2)).
+
+verify_old_secret_stops_working(_Ctx) ->
+    set_secret(?SECRET1),
+    MAC1 = couch_secrets:sign(?DATA1),
+    ?assert(couch_secrets:verify(?DATA1, MAC1)),
+    set_secret(?SECRET2),
+    MAC2 = couch_secrets:sign(?DATA1),
+    ?assert(couch_secrets:verify(?DATA1, MAC2)),
+    ?assert(gen_server:call(couch_secrets, flush_cache) > 0),
+    ?assertNot(couch_secrets:verify(?DATA1, MAC1)),
+    ?assert(couch_secrets:verify(?DATA1, MAC2)).
+
+delete_secret() ->
+    config:delete("chttpd_auth", "secret"),
+    config:delete("couch_httpd_auth", "secret").
+
+set_secret(Secret) ->
+    config:set("chttpd_auth", "secret", Secret),
+    timer:sleep(100).
diff --git a/src/docs/src/config/auth.rst b/src/docs/src/config/auth.rst
index 406ab4512..fac69da7c 100644
--- a/src/docs/src/config/auth.rst
+++ b/src/docs/src/config/auth.rst
@@ -329,6 +329,18 @@ Authentication Configuration
             [chttpd_auth]
             secret = 92de07df7e7a3fe14808cef90a7cc0d91
 
+        .. note::
+           You can change the secret value at any time. New cookies will be
+           signed with the new value and the previous value will be cached
+           for the duration of the auth ``timeout`` parameter.
+
+           The secret value should be set on all nodes at the same time. 
CouchDB
+           will tolerate a discrepancy, however, as each node sends its secret
+           to the other nodes of the cluster.
+
+           The easiest rotation method is to enable the config auto-reload
+           feature then update the secret in the ``.ini`` file of each node.
+
     .. config:option:: timeout :: Session timeout
 
         .. versionchanged:: 3.2 moved from [couch_httpd_auth] to [chttpd_auth] 
section

Reply via email to