It may not be believable considering how often even to this day
plain-text passwords get leaked, but the history of the password hash is
a long one. Way back when I was just starting web dev 22 some years
ago, the common practice was when a user creates an account you hash
their password and store that value. Then every time they log in you
take what they give you, hash it, see if the hash matched what you
stored.
At that time getting access to crypto libs from web
applications was pretty painful and it wasn't uncommon that we would be
like "eh, fukkit" and end up using MD5 or SHA to hash before
storing. Fast forward over time and really all I ever did to our systems
was update which version of SHA was being used, slowly migrating to
longer and longer hashes, adding in salts (which my system called
Shifting Sands). The problem that was trying to solve was not so much
making it harder to guess a password but make it harder to have your
dataset match a rainbow table in the event the database was compromised.
A
completely moot point as if someone does have a copy of the data
locally it is pretty trivial to blaze through a huge dictionary on
modern CPU and GPUs.
The main point of crypto designed hashing is
for the most part, really just to be slow on purpose. If you do get
dumped it is better that you maybe get a week to find out and deal
before live auth gets violated, rather than like the 20 seconds it
probably takes otherwise with simple hashes. PHP has had password_hash and password_verify
since 5.5.0 (2013), but I don't think it really became a strong
contender until PHP 7.2 (2017) when support for the Argon algo was
added. With the ease of access now to a quality algo, not using it is be
pretty embarrassing.
In a hash based auth system that has been
what I would consider, engineered and abstracted to the proper level,
there should really only be two points of contact where modifications
should be needed. So how much effort was needed to stop being lazy and
actually get this done?
User Creation / Password Updating
When an account is created in the system you need things like their email, username, and the password they want to use. After you run basic checks on their password like, is it long enough, does it contain enough character variation that you demand, you would then generate the hash and insert it into the dataset. The short of it is you should have one function that takes a String Password, and gives you back a String Hash. In this case it it was our User::Insert method's subfunction, User::Insert_ValidatePassword. Additionally when you change your password the Password app actually reuses the User::Insert_ValidatePassword method so we're really down to a single point for hash generation.
// old code
$Opt->PHash = hash('sha512',$Opt->Password1);
// new code
// rather than remember the names of the algos it was added to the config file.
$Opt->PHash = password_hash(
$Opt->Password1,
Nether\Option::Get('Atlantis.User.Password.Algo')
);
1 line of code, user creation and password updating is done*
User Log In
The second point of contact is allowing the user to log in with their
password. We already updated the generation of these hashes so we are
left with validation of a password against it. When they hit our login
form we need their user name/id and their password. Assuming they gave
us a valid user our user object then has a single method User::IsValidPassword.
* I didn't mention it before but when a user changes their password, it also uses the User::IsValidPassword method as we ask for the old password before allowing the new one to be changed - even if the user already has a valid session.
// old code
return hash('sha512',$Password) === $this->PHash;
// new code.
return password_verify($Password,$this->PHash);
1 line of code, user auth is done.
NOT QUITE DONE
Two lines of code and if we are setting up a new project from fresh we're technically done. But if you already have a database full of old hashes, none of the users are going to be able to log in anymore. So two things need to be added, a fallback to the old hash() if we infact still have an old hash for this user, and a way to update the the hash to a new version automatically. I elected to modify the User::IsValidPassword method again to add the fallback as well as force a hash update automatically if needed.
public function
IsValidPassword(String $Password):
Bool {
/*//
@date 2017-02-11
@updated 2020-10-02
does the specified password match the one belonging to this user?
//*/
$Valid = FALSE;
$Algo = Nether\Option::Get('Atlantis.User.Password.Algo');
////////
if(!isset($this->PHash))
throw new Exception('cannot validate hash from saftey instance');
// using the php password api
if(strpos($this->PHash,'$') === 0)
$Valid = password_verify($Password,$this->PHash);
// falling back to the old old old.
else
$Valid = (hash('sha512',$Password) === $this->PHash);
// if the password needs rehashed because we changed the server
// settings then lets do it now since we have it in our grasp.
if($Valid && password_needs_rehash($this->PHash,$Algo))
$this->Update([ 'PHash' => password_hash($Password,$Algo) ]);
return $Valid;
}
If our hash begins with a dollar sign that means it was generated as a
crypto format rather than just being our flat SHA512 so that can be
used to check which method of validation to use. Instead of just
instantly returning the success or fail of this check we make a note of
it.
If and only if the password was valid, we introduce a check for password_needs_rehash,
which will return true if the hashes are not crypto hashes (so our
SHA512's) or if the options for computation has changed since the hash
was generated. If it requests a rehash while we have their raw password,
generate and push it in now just get it done.
Unfortunately we
cannot just bulk update all users - you need to have the actual original
password to generate the updated hash. You better not have stored a
copy of that in plain text anywhere... but now any time we need to
validate a password for any reason user accounts will get updated. If
felt it was important that all users update their hashes ASAP, we would
probably just flat out blank out all old hashes and force a user to use
the `Forgot Your Password?` feature of the login system to confirm their
identity and generate a new login hash (with I mean, an email
explaining this in detail).
Taking it A Little Farther
Both password_hash and password_needs_rehash have a
third argument where additional options can be passed. PHP's default
options if you do not specify any (we did not in any of the examples)
are decent, but if you can increase any of the settings from their
defaults that increase the time it takes to calculate a hash, you are
only making it "better" - when calculating a hash we talk about its
'cost' or how hard it is for the computer to finish it. Be sure to take
a
look at the available options to evaluate if you can accept the additional costs of computation.
People considering themselves academics will point out there is
potentially a "timing attack" involved with your login system, where,
with microtime measurements, the attack can determine if a user even
exists in your database based on the time it took to check, return, and
then attempt a password validation. What happens is if it finds a user,
and then attempts validation, it is going to take (to what
microprocessors consider) a much longer time to return an error, than if
you found no user at all and immediately returned. Is the solution, if
no user is found, crypt some random ass string just to waste time? This
was not the problem I am attempting to solve immediately.
TL;DR
I waited way too long to update something that was trivial to update. If you been sitting on this just do it already.