Add post abourt converting Werkzeug hashes to Passlib format
This commit is contained in:
		
							
								
								
									
										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) | ||||
		Reference in New Issue
	
	Block a user