Add post abourt converting Werkzeug hashes to Passlib format
This commit is contained in:
parent
d3b11a083a
commit
8b7dd42b27
129
content/blog/converting-werkzeug-hashes-to-passlib.rst
Normal file
129
content/blog/converting-werkzeug-hashes-to-passlib.rst
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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)
|
Loading…
Reference in New Issue
Block a user