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
