130 lines
5.6 KiB
ReStructuredText
130 lines
5.6 KiB
ReStructuredText
Converting werkzeug’s 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 Python’s
|
||
`hashlib <https://docs.python.org/3.8/library/hashlib.html>`_ module under the hood which,
|
||
unfortunately to me, doesn’t 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 doesn’t understand Werkzeug’s 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, let’s 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 (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
|
||
<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, let’s verify everything went right:
|
||
|
||
.. code-block:: python
|
||
|
||
>>> 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):
|
||
|
||
.. code-block:: python
|
||
|
||
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)
|