From 8b7dd42b27d4be2d97a76fb5e062df83bde06184 Mon Sep 17 00:00:00 2001 From: Gergely Polonkai Date: Sun, 19 Jul 2020 08:24:02 +0200 Subject: [PATCH] Add post abourt converting Werkzeug hashes to Passlib format --- .../converting-werkzeug-hashes-to-passlib.rst | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 content/blog/converting-werkzeug-hashes-to-passlib.rst diff --git a/content/blog/converting-werkzeug-hashes-to-passlib.rst b/content/blog/converting-werkzeug-hashes-to-passlib.rst new file mode 100644 index 0000000..9686a43 --- /dev/null +++ b/content/blog/converting-werkzeug-hashes-to-passlib.rst @@ -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 `_’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: + +.. 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 +`_ +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. + +.. 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)