Converting werkzeug’s hashes to Passlib format

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:

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.

>>> 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:

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.

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):

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.

passlib_final_hash = f'${method}-{digest}${rounds}${passlib_salt}${passlib_hash}'

Finally, let’s verify everything went right:

>>> 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):

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)

contacts & more