Skip to content

Commit 1b721b0

Browse files
committed
Implement auth token hashing
I want to make this clear: > This is **NOT** intended to be a "secure" scheme. As everything is stored in memory that is our largest security measure here. What this is intended to do is add a additional layer of computation in case the data is leaked; and to add a minor obfuscation against internal employees attempting to view the data in-memory. This uses the same general token hashing algorithm that Devise uses for things like password recovery tokens. The Devise scheme is as follows: +-------------+ | column_name |---------+ +------+------+ | +--------------------+ | | | memory | | | |--------------------| +--v--+ | | | +------------------+ | | +-----+ | | +------------+ | | Rails secret_key +---> KDF +---> key |---+----->| cached key | | +------------------+ | | +-----+ | +------------+ | +-----+ | | | +---------|----------+ | +------------------------------+ | | +-------------------------+ | | database | | |-------------------------| +--v---+ | | +-------+ | | +--------+ | +-----------------+ | | token +---> HMAC +---> digest |------>| digested record | | +-------+ | | +--------+ | +-----------------+ | +------+ | | +-------------------------+ This implementation differs slightly from the above. Namely, this does not use the KDF based on the Rails secret token. Instead we use a time sensitive rotating key generated purely from `SecureRandom`: +----------------------+ | memory | |----------------------| | | | +--------------+ | +-----------------------+| rotating key | | | | +--------------+ | | | | +--v---+ | | +-------+ | | +--------+ | +--------------+ | | token +---> HMAC +---> digest |------>| token digest | | +-------+ | | +--------+ | +--------------+ | +------+ | | +----------------------+ This does not lessen the security. While the KDF is intentionally slow, the implementation Devise uses caches the result. This means the expensive computation is only performed once then stored in-memory. However, it is always computed the same way using PBKDF2-HMAC-SHA1 based on the Rails secret key and the salt "Devise COLUMN_NAME"; the security there is purely in keeping the Rails secret key secret (which is stored in memory). In our implementation, the secret is only ever stored in memory and always generated via `SecureRandom`. Thus there's no way to try to pre-compute the value without exploiting `SecureRandom`. Additionally, instead of being completely fixed, we change it at a relatively frequent interval. Thus if there is a compromise of the data (which is in-memory so either the system is compromised or there's a remote code execution vulnerability which means way worse problems) only a subset of the data from that time window is available. If more data is dumped again later, the key has changed; _nominally_ increasing computation costs of the attacker (similar, but not exactly like, a unique salt per token). Why not just salt+hash each token? ---------------------------------- The main problem with token auth is there's no way to look up the token to know which salt to use. This means you would essentially need to run through all of the salts to try to find a match. If we had thought about this more in the beginning we could have considered choosing longer tokens and using part of them as the identifying key. Thus allowing us to look up the specific token and treating the process just like a regular login. References ---------- - https://en.wikipedia.org/wiki/HMAC - https://crypto.stackexchange.com/questions/34864/key-size-for-hmac-sha256/34866#34866 - [Devise "generating" reset token](https://github.com/plataformatec/devise/blob/v4.4.3/lib/devise/models/recoverable.rb#L90) - [Devise algorithm](https://github.com/plataformatec/devise/blob/v4.4.3/lib/devise/token_generator.rb#L21) - [Devise config Rails KDF](https://github.com/plataformatec/devise/blob/v4.4.3/lib/devise/rails.rb#L41-L43) - [Rails KDF](https://github.com/rails/rails/blob/v5.2.0/activesupport/lib/active_support/key_generator.rb#L23)
1 parent 2d48fcb commit 1b721b0

File tree

2 files changed

+105
-13
lines changed

2 files changed

+105
-13
lines changed

lib/kracken/controllers/token_authenticatable.rb

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,30 @@ def request_http_token_authentication(realm = 'Application')
3636

3737
module_function
3838

39+
# @private
40+
def auth_cache_key(token)
41+
# This key must **ALWAYS** be generated and stored either in-memory or
42+
# "securely" on the server. Treat this like the Rails / Devise secret
43+
# key.
44+
#
45+
# If in the future we move back to an external cache (say Redis) for
46+
# the cached auth token storage this must still remain **ONLY** on the
47+
# server.
48+
key = TokenAuthenticatable.cache.fetch("KEY", AUTH_KEY_OPTS) {
49+
# Clear all existing data generated by previous key
50+
clear_auth_cache
51+
# HMAC-SHA256 takes a maximum key size of 64-bytes
52+
# See https://crypto.stackexchange.com/questions/34864/key-size-for-hmac-sha256/34866#34866
53+
SecureRandom.random_bytes(64)
54+
}
55+
OpenSSL::HMAC.digest("SHA256", key, token)
56+
end
57+
3958
def cache_valid_auth(token, force: false, &generate_cache)
40-
cache_key = token
41-
val = TokenAuthenticatable.cache.read(cache_key) unless force
42-
val ||= store_valid_auth(cache_key, &generate_cache)
59+
cache_key = auth_cache_key(token)
60+
val, nonce, hmac = TokenAuthenticatable.cache.read(cache_key) unless force
61+
val = nil unless hmac == OpenSSL::HMAC.digest("SHA256", "#{nonce}#{token}", val.to_s)
62+
val ||= store_valid_auth(token, cache_key, &generate_cache)
4363
shallow_freeze(val)
4464
end
4565

@@ -52,14 +72,29 @@ def shallow_freeze(val)
5272
val.each { |_k, v| v.freeze }.freeze
5373
end
5474

55-
def store_valid_auth(cache_key)
56-
val = yield
57-
TokenAuthenticatable.cache.write(cache_key, val, CACHE_TTL_OPTS) if val
75+
def store_valid_auth(token, cache_key = auth_cache_key(token))
76+
return unless (val = yield(token))
77+
78+
# HMAC-SHA256 takes a maximum of 64-bytes for the key. When data is
79+
# longer it is hashed, truncating to 32-bytes. We add the nonce here to
80+
# force us over that 64-byte limit, thus hashing the result. This is to
81+
# ensure at least 32-bytes of entropy from the token. This HMAC is
82+
# mearly meant as a hash collision guard, not as added security.
83+
nonce = SecureRandom.random_bytes(64)
84+
hmac = OpenSSL::HMAC.digest("SHA256", "#{nonce}#{token}", val.to_s)
85+
TokenAuthenticatable.cache.write cache_key,
86+
[val, nonce, hmac].freeze,
87+
CACHE_TTL_OPTS
5888
val
5989
end
6090

6191
private
6292

93+
AUTH_KEY_OPTS = {
94+
expires_in: 5.minutes, # Rotate key relatively frequently
95+
race_condition_ttl: 1.second,
96+
}.freeze
97+
6398
CACHE_TTL_OPTS = {
6499
expires_in: ENV.fetch("KRACKEN_TOKEN_TTL", 1.minute).to_i,
65100
race_condition_ttl: 1.second,

spec/kracken/controllers/token_authenticatable_spec.rb

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ def authenticate_or_request_with_http_token(realm = nil)
7272
'HTTP_AUTHORIZATION' => "Token token=\"#{cached_token}\""
7373
}
7474

75-
Controllers::TokenAuthenticatable.cache.write cache_key, auth_info
75+
Controllers::TokenAuthenticatable.store_valid_auth cached_token do
76+
auth_info
77+
end
7678
stub_const "Kracken::Authenticator", spy("Kracken::Authenticator")
7779
end
7880

@@ -163,7 +165,9 @@ def authenticate_or_request_with_http_token(realm = nil)
163165
expect {
164166
a_controller.authenticate_user_with_token!
165167
}.not_to change {
166-
Controllers::TokenAuthenticatable.cache.exist?("#{invalid_token}")
168+
Controllers::TokenAuthenticatable.cache.exist?(
169+
Controllers::TokenAuthenticatable.auth_cache_key(invalid_token)
170+
)
167171
}.from false
168172
end
169173
end
@@ -218,20 +222,28 @@ def authenticate_or_request_with_http_token(realm = nil)
218222
expect(a_controller.current_team_ids).to be_frozen
219223
end
220224

221-
it "sets the auth info as the cache value" do
225+
it "sets the auth info, along with a nonce, and HMAC as the cache value" do
222226
expect {
223227
a_controller.authenticate_user_with_token!
224228
}.to change {
225-
Controllers::TokenAuthenticatable.cache.read("any token")
229+
Controllers::TokenAuthenticatable.cache.read(
230+
Controllers::TokenAuthenticatable.auth_cache_key("any token")
231+
)
226232
}.from(nil).to(
227-
id: :any_id,
228-
team_ids: [:some, :team, :ids],
233+
[
234+
{
235+
id: :any_id,
236+
team_ids: [:some, :team, :ids],
237+
},
238+
be_a(String).and(satisfy { |s| s.size == 64 }),
239+
be_a(String).and(satisfy { |s| s.size == 32 }),
240+
]
229241
)
230242
end
231243

232244
it "sets the cache expiration to one minute by default" do
233245
expect(Controllers::TokenAuthenticatable.cache).to receive(:write).with(
234-
"any token",
246+
Controllers::TokenAuthenticatable.auth_cache_key("any token"),
235247
anything,
236248
include(expires_in: 1.minute),
237249
)
@@ -243,6 +255,51 @@ def authenticate_or_request_with_http_token(realm = nil)
243255
a_controller.authenticate_user_with_token!
244256
expect(a_controller.current_user).to be a_user
245257
end
258+
259+
it "treats an HMAC verification failure as a miss" do
260+
initial_salt = "salt" * 16
261+
initial_hmac = "hmac" * 8
262+
cache_key = Controllers::TokenAuthenticatable.auth_cache_key(
263+
"any token"
264+
)
265+
Controllers::TokenAuthenticatable.cache.write(
266+
cache_key,
267+
[
268+
{
269+
id: :any_id,
270+
team_ids: [:some, :team, :ids],
271+
},
272+
initial_salt,
273+
initial_hmac,
274+
],
275+
)
276+
277+
expect {
278+
a_controller.authenticate_user_with_token!
279+
}.to change {
280+
Controllers::TokenAuthenticatable.cache.read(cache_key)
281+
}.from(
282+
[
283+
{
284+
id: :any_id,
285+
team_ids: [:some, :team, :ids],
286+
},
287+
initial_salt,
288+
initial_hmac,
289+
],
290+
).to(
291+
[
292+
{
293+
id: :any_id,
294+
team_ids: [:some, :team, :ids],
295+
},
296+
be_a(String).and(satisfy { |s| s.size == 64 && s != initial_salt}),
297+
be_a(String).and(satisfy { |s| s.size == 32 && s != initial_hmac}),
298+
]
299+
)
300+
expect(Authenticator).to have_received(:user_with_token)
301+
.with(valid_token)
302+
end
246303
end
247304
end
248305
end

0 commit comments

Comments
 (0)