In one of my projects i use Werkzeug’s generate_password_hash to, well, generate password hashes. Werkzeug uses Python’s hashlib module under the hood which, unfortunately to me, doesn’t support Argon2 (which i want to transition to for greater security).
Enter Passlib.
Passlib conveniently supports both PBKDF2 (what i currently use in the project, using SHA256 digests), and also supports Argon2. Here are the differences:
Feature | Werkzeug | Passlib |
---|---|---|
Iteration count | 150000, hardcoded | 29000, changeable |
Default method | PBKDF2 | None, must be set explicitly |
Default digest | SHA-256 | None, must be set explicitly |
Default salt size | 8, changeable | 16, changeable |
Salt character set | ASCII only | Binary |
Salt storage | Plain text (shorter) | Adapted Base64 |
Hash storage | Hex string | Adapted Base64 (shorter) |
But even if i force the same settings on Passlib, their format is different and, obviously, Passlib doesn’t understand Werkzeug’s format so i had to convert all my hashes to the new format.
>>> generate_password_hash('password', method='pbkdf2:sha256', salt_length=8)
'pbkdf2:sha256:150000$2dFFA24B$1602ed71733451acaf29abd26b1d1a78aced4442467f9efbab9b1fa21ae39d68'
>>> pbkdf2_sha256.using(rounds=150000, salt_size=8).hash('password')
'$pbkdf2-sha256$29000$tdZ6731vzTnn/H9vTSnl3A$maWqohBS0ghQEjIoJWnYC1zGF2T/qOwRmHzVzHt3NqU'
First, let’s split the old hash by the $ characters, and also split the first part by colons:
full_method, salt, hashed_value = old_hash.split('$')
method, digest, rounds = full_method.split(':')
As it soon turned out, the two libraries even store the actual data in different formats. Werkzeug stores the salt in plain text (it’s always ASCII characters), and the resulting hash in a hex string. Passlib, however, aims for greater security with a binary salt, so it’s Base64 encoded. This encoding is, however, a bit different from what you’d expect, as it’s “using shortened base64 format which omits padding & whitespace” and also “uses custom ./ altchars”. It even has its own ab64_encode and ab64_decode functions to handle this encoding. So i first need to re-encode both the salt string and the hash value hex string, so i have raw bytes.
salt_bytes = salt.encode('ascii')
hash_bytes = bytes.fromhex(hash)
Then encode them to the adapted Base64 format (i also convert it back to str for easier concatenation later):
passlib_salt = ab64_encode(salt_bytes).decode('ascii')
passlib_hash = ab64_encode(hash_bytes).decode('ascii')
Now we just need to concatenate all the things with the right separators.
passlib_final_hash = f'${method}-{digest}${rounds}${passlib_salt}${passlib_hash}'
Finally, let’s verify everything went right:
>>> pbkdf2_sha256.verify('password', passlib_final_hash)
True
Here’s the whole series of command, converted to a Python function (with slightly altered variable names) for your copy-pasting convenience (plus, it’s not using f-strings, so you can use it with Python <3.6):
def werkzeug_to_passlib_hash(old_hash):
"""Convert Werkzeug’s password hashes to Passlib format.
Copied from https://gergely.polonkai.eu/blog/converting-werkzeug-hashes-to-passlib/
"""
from passlib.utils import ab64_encode
# Werkzeug hashes look like full_method$salt$hash. We handle full_method later; salt is
# an ASCII string; hashed value is the hex string representation of the hashed value
full_method, salt, hashed_value = old_hash.split('$')
# Werkzeug’s full_method is a colon delimited list of the method, digest, and rounds
method, digest, rounds = full_method.split(':')
new_parts = [
# Passlib expects the hashed value to starts with a $ sign (hence the empty string at the
# beginning of this list).
'',
# Passlib’s method and digest is concatenated together with a hyphen.
f'{method}-{digest}',
rounds,
# Passlib expects the salt and the actual hash to be in the adapted base64 encoding format.
ab64_encode(salt.encode('ascii')).decode('ascii'),
ab64_encode(bytes.fromhex(hashed_value)).decode('utf-8'),
]
return '$'.join(new_parts)