I have a few requirements for a password reset system, along with some questions at the end as to its security.
Requirements
The aim is to provide a password reset mechanism satisfying the following properties:
- Multiple tokens per user, to allow for email delays to not cause confusion.
- Tokens are single-use and expire.
- Someone with read-only access to a database of reset information cannot easily reset a user's password.
- Resistant to replay attacks, timing attacks, and (potentially) insecure random number generators. See below.
Assumptions / details
- There is a secret random value that won't be known outside of the application.
- Once a password reset token is used by a user to reset their password, all available tokens for a user are deleted from the database.
Outline
Note that ||
here is concatenation. The approach given is a modified version of the Devise authentication library. Specifically, the modification is that there are now multiple tokens.
Let S
be a fixed secret picked ahead of time.
To construct a reset token for a user U
, generate a (secure) random number r
and consider the pair
(id, r)
where id
is picked from an incrementing counter value. Generate
S' = PBKDF2(S)
with a salt and let S'
be the key for the HMAC value
h = HMAC(S', id || r).
Store the reset information
(U, id, h, e),
with e
the expiration time, before sending (id, r)
to the user.
When someone tries to reset their password, they give us user-provided values for the id
and for r
:
(id_user, r_user).
Use id_user
to find the previously stored (U, id, h, e)
information.
If id_user
corresponds to no such information, then there is nothing to do. If e
is in the past, then the token has expired.
Generate S' = PBKDF2(S)
again, and compare
HMAC(S', id_user || r_user)
with the previous value of h, which was
HMAC(S', id || r).
If these two values match (see note below), then the token is valid and we allow the user U
to reset their password. When they do, all stored reset information (the (U, id, h, e)
entries for the given U
) is deleted.
Notes
Hopefully the first few requirements are obvious met (multiple tokens, etc.). The main one worth explaining is probably: someone with read-only access to a database of reset information cannot easily reset a user's password.
Because the token value itself is not stored in a database, only the corresponding HMAC, someone who can read the database cannot construct a valid reset password URL.
As for the equality checking above: if the user tampers with the values of (id, r)
, they will generate a radically different value of h
which should avoid exposing timing information.
The inclusion of S' = PBKDF(S)
(with a salt) is to make the generation of a HMAC value more costly, since a token should have similar properties to passwords.
Allowing for predictable random numbers
If the random number generator ever produces a number that an attacker can predict, then it's easy enough to find the correct ID value, and thus get a valid reset link.
At least as of this writing, some number generators rely on user-space algorithms, which could be a problem: http://sockpuppet.org/blog/2014/02/25/safely-generate-random-numbers/
To be paranoid about it (the system in question involves various pieces of personal information) and get around the issue of a predictable random number, we can try sending over the HMAC value to the user so we can verify authenticity.
Before, we were sending
(id, r)
to the user, which was an incrementing ID counter value and the random number. Instead, send the user
(id, r, h).
When handling a reset request, we'll receive
(id_user, r_user, h_user).
We can take this value and verify that neither id_user
, nor r_user
have been tampered with, by checking h_user
is equal to HMAC(S', id_user || r_user)
.
With that verification step completed, we can use id_user
to find (U, id, h, e)
and continue as before.
If the random number r
(and thus (id, r)
) is ever predicted by an attacker, they won't know how to generate a valid HMAC, since it requires S'
which is secret and the pair (id, r)
has never been seen before.
Note that we don't ever use h_user
to look up the (U, id, h, e)
information, so the database shouldn't ever expose a timing attack in its look-up algorithm.
Weirdness and questions
- Is it actually secure against timing attacks, replay attacks, etc.? This feels borderline roll-your-own-crypto. Are there issues?
- Is there a vastly simpler way?
- What security properties are reduced by allowing an attacker to generate an arbitrary number of reset tokens, since we're allowing multiple tokens to exist and anyone can send a reset?
- Is it unreasonable to try and defend against an ostensibly secure random number generator's hypothetical future bugs? (Edit: reworded for clarity.)
- If this scheme is secure against predictable
r
values, then isn't it equivalent to using only(id, h)
provided thatid
is turned into a GUID/UUID?
Update:
I suspect it actually is unreasonable to try and defend against, e.g., OpenSSL randomness bugs, which leads me to another issue with the above: what's wrong with simply storing (U, PBKDF2(r))
and sending r
to the user with no HMAC? If there are enough bits in r
then isn't this secure enough given that tokens expire within <= 6 hours?