Skip to content

Commit ae2bca2

Browse files
authored
Merge pull request #5754 from apache/rotate-secret
Support safe secret rotation
2 parents bbbbfc8 + 329920e commit ae2bca2

5 files changed

Lines changed: 325 additions & 31 deletions

File tree

src/couch/src/couch_httpd_auth.erl

Lines changed: 38 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,15 @@ proxy_auth_user(Req) ->
209209
case chttpd_util:get_chttpd_auth_config("secret") of
210210
undefined ->
211211
Req#httpd{user_ctx = #user_ctx{name = ?l2b(UserName), roles = Roles}};
212-
Secret ->
213-
HashAlgorithms = couch_util:get_config_hash_algorithms(),
214-
Token = header_value(Req, XHeaderToken),
215-
VerifyTokens = fun(HashAlg) ->
216-
Hmac = couch_util:hmac(HashAlg, Secret, UserName),
217-
couch_passwords:verify(couch_util:to_hex(Hmac), Token)
218-
end,
219-
case lists:any(VerifyTokens, HashAlgorithms) of
212+
_Secret ->
213+
Token =
214+
try
215+
binary:decode_hex(?l2b(header_value(Req, XHeaderToken)))
216+
catch
217+
error:badarg ->
218+
undefined
219+
end,
220+
case couch_secrets:verify(UserName, Token) of
220221
true ->
221222
Req#httpd{
222223
user_ctx = #user_ctx{
@@ -355,35 +356,30 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
355356
end,
356357
% Verify expiry and hash
357358
CurrentTime = make_cookie_time(),
358-
HashAlgorithms = couch_util:get_config_hash_algorithms(),
359359
case chttpd_util:get_chttpd_auth_config("secret") of
360360
undefined ->
361361
couch_log:debug("cookie auth secret is not set", []),
362362
Req;
363-
SecretStr ->
364-
Secret = ?l2b(SecretStr),
363+
_SecretStr ->
365364
case AuthModule:get_user_creds(Req, User) of
366365
nil ->
367366
Req;
368367
{ok, UserProps, _AuthCtx} ->
369368
UserSalt = couch_util:get_value(<<"salt">>, UserProps, <<"">>),
370-
FullSecret = <<Secret/binary, UserSalt/binary>>,
371369
Hash = ?l2b(HashStr),
372-
VerifyHash = fun(HashAlg) ->
373-
Hmac = couch_util:hmac(
374-
HashAlg,
375-
FullSecret,
376-
lists:join(":", [User, TimeStr])
377-
),
378-
couch_passwords:verify(Hmac, Hash)
379-
end,
380370
Timeout = chttpd_util:get_chttpd_auth_config_integer(
381371
"timeout", 600
382372
),
383373
couch_log:debug("timeout ~p", [Timeout]),
384374
case (catch list_to_integer(TimeStr, 16)) of
385375
TimeStamp when CurrentTime < TimeStamp + Timeout ->
386-
case lists:any(VerifyHash, HashAlgorithms) of
376+
case
377+
couch_secrets:verify(
378+
lists:join(":", [User, TimeStr]),
379+
UserSalt,
380+
Hash
381+
)
382+
of
387383
true ->
388384
TimeLeft = TimeStamp + Timeout - CurrentTime,
389385
couch_log:debug(
@@ -398,7 +394,7 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
398394
)
399395
},
400396
auth =
401-
{FullSecret, TimeLeft < Timeout * 0.9}
397+
{UserSalt, TimeLeft < Timeout * 0.9}
402398
};
403399
_Else ->
404400
Req
@@ -413,7 +409,7 @@ cookie_authentication_handler(#httpd{mochi_req = MochiReq} = Req, AuthModule) ->
413409
cookie_auth_header(#httpd{user_ctx = #user_ctx{name = null}}, _Headers) ->
414410
[];
415411
cookie_auth_header(
416-
#httpd{user_ctx = #user_ctx{name = User}, auth = {Secret, _SendCookie = true}} =
412+
#httpd{user_ctx = #user_ctx{name = User}, auth = {UserSalt, _SendCookie = true}} =
417413
Req,
418414
Headers
419415
) ->
@@ -430,21 +426,20 @@ cookie_auth_header(
430426
if
431427
AuthSession == undefined ->
432428
TimeStamp = make_cookie_time(),
433-
[cookie_auth_cookie(Req, User, Secret, TimeStamp)];
429+
[cookie_auth_cookie(Req, User, UserSalt, TimeStamp)];
434430
true ->
435431
[]
436432
end;
437433
cookie_auth_header(_Req, _Headers) ->
438434
[].
439435

440-
cookie_auth_cookie(Req, User, Secret, TimeStamp) ->
436+
cookie_auth_cookie(Req, User, UserSalt, TimeStamp) ->
441437
SessionItems = [User, integer_to_list(TimeStamp, 16)],
442-
cookie_auth_cookie(Req, Secret, SessionItems).
438+
cookie_auth_cookie(Req, UserSalt, SessionItems).
443439

444-
cookie_auth_cookie(Req, Secret, SessionItems) when is_list(SessionItems) ->
440+
cookie_auth_cookie(Req, UserSalt, SessionItems) when is_list(SessionItems) ->
445441
SessionData = lists:join(":", SessionItems),
446-
[HashAlgorithm | _] = couch_util:get_config_hash_algorithms(),
447-
Hash = couch_util:hmac(HashAlgorithm, Secret, SessionData),
442+
Hash = couch_secrets:sign(SessionData, UserSalt),
448443
mochiweb_cookies:cookie(
449444
"AuthSession",
450445
couch_util:encodeBase64Url(lists:join(":", [SessionData, Hash])),
@@ -465,11 +460,23 @@ ensure_cookie_auth_secret() ->
465460
undefined ->
466461
NewSecret = ?b2l(couch_uuids:random()),
467462
config:set("chttpd_auth", "secret", NewSecret),
463+
wait_for_secret(10),
468464
NewSecret;
469465
Secret ->
470466
Secret
471467
end.
472468

469+
wait_for_secret(0) ->
470+
ok;
471+
wait_for_secret(N) ->
472+
case couch_secrets:secret_is_set() of
473+
true ->
474+
ok;
475+
false ->
476+
timer:sleep(50),
477+
wait_for_secret(N - 1)
478+
end.
479+
473480
% session handlers
474481
% Login handler with user db
475482
handle_session_req(Req) ->
@@ -514,11 +521,11 @@ handle_session_req(#httpd{method = 'POST', mochi_req = MochiReq} = Req, AuthModu
514521
Req, UserName, Password, UserProps, AuthModule, AuthCtx
515522
),
516523
% setup the session cookie
517-
Secret = ?l2b(ensure_cookie_auth_secret()),
524+
ensure_cookie_auth_secret(),
518525
UserSalt = couch_util:get_value(<<"salt">>, UserProps),
519526
CurrentTime = make_cookie_time(),
520527
Cookie = cookie_auth_cookie(
521-
Req, UserName, <<Secret/binary, UserSalt/binary>>, CurrentTime
528+
Req, UserName, UserSalt, CurrentTime
522529
),
523530
% TODO document the "next" feature in Futon
524531
{Code, Headers} =

src/couch/src/couch_secondary_sup.erl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ init([]) ->
2424
],
2525
Daemons =
2626
[
27+
{couch_secrets, {couch_secrets, start_link, []}},
2728
{query_servers, {couch_proc_manager, start_link, []}},
2829
{vhosts, {couch_httpd_vhost, start_link, []}},
2930
{uuids, {couch_uuids, start, []}},

src/couch/src/couch_secrets.erl

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
% Licensed under the Apache License, Version 2.0 (the "License"); you may not
2+
% use this file except in compliance with the License. You may obtain a copy of
3+
% the License at
4+
%
5+
% http://www.apache.org/licenses/LICENSE-2.0
6+
%
7+
% Unless required by applicable law or agreed to in writing, software
8+
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9+
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10+
% License for the specific language governing permissions and limitations under
11+
% the License.
12+
13+
-module(couch_secrets).
14+
15+
-behaviour(gen_server).
16+
-behaviour(config_listener).
17+
18+
-include_lib("couch/include/couch_db.hrl").
19+
20+
%% public api
21+
-export([sign/1, sign/2, verify/2, verify/3, secret_is_set/0]).
22+
23+
%% gen_server functions
24+
-export([
25+
start_link/0,
26+
init/1,
27+
handle_call/3,
28+
handle_cast/2,
29+
handle_continue/2,
30+
handle_info/2
31+
]).
32+
33+
%% config_listener functions
34+
-export([
35+
handle_config_change/5,
36+
handle_config_terminate/3
37+
]).
38+
39+
sign(Message) ->
40+
sign(Message, <<>>).
41+
42+
sign(Message, ExtraSecret) ->
43+
[HashAlgorithm | _] = couch_util:get_config_hash_algorithms(),
44+
case current_secret_from_ets() of
45+
undefined ->
46+
throw({internal_server_error, <<"cookie auth secret is not set">>});
47+
CurrentSecret ->
48+
FullSecret = <<CurrentSecret/binary, ExtraSecret/binary>>,
49+
couch_util:hmac(HashAlgorithm, FullSecret, Message)
50+
end.
51+
52+
verify(Message, ExpectedMAC) ->
53+
verify(Message, <<>>, ExpectedMAC).
54+
55+
verify(Message, ExtraSecret, ExpectedMAC) ->
56+
FullSecrets = [<<Secret/binary, ExtraSecret/binary>> || Secret <- all_secrets_from_ets()],
57+
AllAlgorithms = couch_util:get_config_hash_algorithms(),
58+
verify(Message, AllAlgorithms, FullSecrets, ExpectedMAC).
59+
60+
verify(Message, AllAlgorithms, FullSecrets, ExpectedMAC) ->
61+
Algorithms = lists:filter(
62+
fun(Algorithm) ->
63+
#{size := Size} = crypto:hash_info(Algorithm),
64+
Size == byte_size(ExpectedMAC)
65+
end,
66+
AllAlgorithms
67+
),
68+
VerifyFun = fun({Secret, Algorithm}) ->
69+
ActualMAC = couch_util:hmac(Algorithm, Secret, Message),
70+
crypto:hash_equals(ExpectedMAC, ActualMAC)
71+
end,
72+
lists:any(VerifyFun, [{S, A} || S <- FullSecrets, A <- Algorithms]).
73+
74+
secret_is_set() ->
75+
current_secret_from_ets() /= undefined.
76+
77+
start_link() ->
78+
gen_server:start_link({local, ?MODULE}, ?MODULE, nil, []).
79+
80+
init(nil) ->
81+
ets:new(?MODULE, [named_table, {read_concurrency, true}]),
82+
true = ets:insert(?MODULE, {{node(), current}, current_secret_from_config()}),
83+
update_all_secrets(),
84+
erlang:send_after(5000, self(), cache_cleanup),
85+
ok = config:listen_for_changes(?MODULE, undefined),
86+
{ok, nil, {continue, get_secrets}}.
87+
88+
handle_call({insert, {Node, current}, Secret}, _From, State) ->
89+
case current_secret_from_ets(Node) of
90+
undefined ->
91+
ets:insert(?MODULE, [{{Node, current}, Secret}]);
92+
OldSecret ->
93+
TimeoutSecs = chttpd_util:get_chttpd_auth_config_integer("timeout", 600),
94+
ExpiresAt = erlang:system_time(second) + TimeoutSecs,
95+
ets:insert(?MODULE, [{{Node, current}, Secret}, {{Node, ExpiresAt}, OldSecret}])
96+
end,
97+
update_all_secrets(),
98+
{reply, ok, State};
99+
handle_call({insert, Key, Secret}, _From, State) ->
100+
ets:insert(?MODULE, {Key, Secret}),
101+
update_all_secrets(),
102+
{reply, ok, State};
103+
handle_call(get_secrets, _From, State) ->
104+
Secrets = ets:match_object(?MODULE, {{node(), '_'}, '_'}),
105+
{reply, Secrets, State};
106+
handle_call(flush_cache, _From, State) ->
107+
%% used from tests to prevent spurious failures due to timing
108+
MatchSpec = [{{{'_', '$1'}, '_'}, [{is_integer, '$1'}], [true]}],
109+
NumDeleted = ets:select_delete(?MODULE, MatchSpec),
110+
if
111+
NumDeleted > 0 -> update_all_secrets();
112+
true -> ok
113+
end,
114+
{reply, NumDeleted, State};
115+
handle_call(_Msg, _From, State) ->
116+
{noreply, State}.
117+
118+
handle_cast(_Msg, State) ->
119+
{noreply, State}.
120+
121+
handle_continue(get_secrets, State) ->
122+
{Replies, _BadNodes} = gen_server:multi_call(nodes(), ?MODULE, get_secrets),
123+
{_Nodes, Secrets} = lists:unzip(Replies),
124+
true = ets:insert(?MODULE, lists:flatten(Secrets)),
125+
update_all_secrets(),
126+
{noreply, State}.
127+
128+
handle_info(restart_config_listener, State) ->
129+
ok = config:listen_for_changes(?MODULE, nil),
130+
update_current_secret(),
131+
{noreply, State};
132+
handle_info(cache_cleanup, State) ->
133+
erlang:send_after(5000, self(), cache_cleanup),
134+
Now = os:system_time(second),
135+
MatchSpec = [{{{'_', '$1'}, '_'}, [{is_integer, '$1'}, {'<', '$1', Now}], [true]}],
136+
NumDeleted = ets:select_delete(?MODULE, MatchSpec),
137+
if
138+
NumDeleted > 0 -> update_all_secrets();
139+
true -> ok
140+
end,
141+
{noreply, State};
142+
handle_info(_Msg, State) ->
143+
{noreply, State}.
144+
145+
handle_config_change("chttpd_auth", "secret", _, _, _) ->
146+
update_current_secret(),
147+
{ok, undefined};
148+
handle_config_change("couch_httpd_auth", "secret", _, _, _) ->
149+
update_current_secret(),
150+
{ok, undefined};
151+
handle_config_change(_, _, _, _, _) ->
152+
{ok, undefined}.
153+
154+
handle_config_terminate(_, stop, _) ->
155+
ok;
156+
handle_config_terminate(_Server, _Reason, _State) ->
157+
erlang:send_after(3000, whereis(?MODULE), restart_config_listener).
158+
159+
%% private functions
160+
161+
update_current_secret() ->
162+
NewSecret = current_secret_from_config(),
163+
spawn(fun() ->
164+
gen_server:multi_call(nodes(), ?MODULE, {insert, {node(), current}, NewSecret}),
165+
gen_server:call(?MODULE, {insert, {node(), current}, NewSecret})
166+
end).
167+
168+
update_all_secrets() ->
169+
AllSecrets = ets:match_object(?MODULE, {{'_', '_'}, '_'}),
170+
ets:insert(?MODULE, {all_secrets, lists:usort([V || {_K, V} <- AllSecrets, is_binary(V)])}).
171+
172+
current_secret_from_config() ->
173+
case chttpd_util:get_chttpd_auth_config("secret") of
174+
undefined ->
175+
undefined;
176+
Secret ->
177+
?l2b(Secret)
178+
end.
179+
180+
current_secret_from_ets() ->
181+
current_secret_from_ets(node()).
182+
183+
current_secret_from_ets(Node) ->
184+
secret_from_ets({Node, current}).
185+
186+
all_secrets_from_ets() ->
187+
secret_from_ets(all_secrets).
188+
189+
secret_from_ets(Key) ->
190+
case ets:lookup(?MODULE, Key) of
191+
[{Key, Value}] -> Value;
192+
[] -> undefined
193+
end.

0 commit comments

Comments
 (0)