Add post abourt converting Werkzeug hashes to Passlib format

This commit is contained in:
Gergely Polonkai 2020-07-19 08:24:02 +02:00
parent d3b11a083a
commit 8b7dd42b27
No known key found for this signature in database
GPG Key ID: 2D2885533B869ED4

View File

@ -0,0 +1,129 @@
Converting werkzeugs hashes to Passlib format
##############################################
:date: 2020-07-19T05:02Z
:category: blog
:tags: python,security
:url: blog/converting-werkzeug-hashes-to-passlib/
:save_as: blog/converting-werkzeug-hashes-to-passlib/index.html
:status: published
:author: Gergely Polonkai
In one of my projects i use `Werkzeug <https://palletsprojects.com/p/werkzeug/>`_s
``generate_password_hash`` to, well, generate password hashes. Werkzeug uses Pythons
`hashlib <https://docs.python.org/3.8/library/hashlib.html>`_ module under the hood which,
unfortunately to me, doesnt support Argon2 (which i want to transition to for greater security).
Enter `Passlib <https://passlib.readthedocs.io/>`_.
Passlib conveniently supports both `PBKDF2
<https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html>`_ (what i currently
use in the project, using SHA256 digests), and also supports `Argon2
<https://passlib.readthedocs.io/en/stable/lib/passlib.hash.argon2.html>`_. Here are the
differences:
.. table::
:widths: auto
================== ==================== ============================
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 doesnt understand Werkzeugs format so i had to convert all my hashes to the new format.
.. code-block:: python
>>> 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, lets split the old hash by the ``$`` characters, and also split the first part by colons:
.. code-block:: python
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 (its always ASCII characters), and the resulting hash in a
hex string. Passlib, however, aims for greater security with a binary salt, so its Base64
encoded. This encoding is, however, a bit different from what youd expect, as its “using
shortened base64 format which omits padding & whitespace” and also “uses custom ``./`` altchars”.
It even has its own `ab64_encode
<https://passlib.readthedocs.io/en/stable/lib/passlib.utils.binary.html?highlight=ab64_encode#passlib.utils.binary.ab64_encode>`_
and `ab64_decode
<https://passlib.readthedocs.io/en/stable/lib/passlib.utils.binary.html?highlight=ab64_encode#passlib.utils.binary.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.
.. code-block:: python
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):
.. code-block:: python
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.
.. code-block:: python
passlib_final_hash = f'${method}-{digest}${rounds}${passlib_salt}${passlib_hash}'
Finally, lets verify everything went right:
.. code-block:: python
>>> pbkdf2_sha256.verify('password', passlib_final_hash)
True
Heres the whole series of command, converted to a Python function (with slightly altered variable
names) for your copy-pasting convenience (plus, its not using f-strings, so you can use it with
Python <3.6):
.. code-block:: python
def werkzeug_to_passlib_hash(old_hash):
"""Convert Werkzeugs 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('$')
# Werkzeugs 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).
'',
# Passlibs 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)