8
votes

I'm porting a bunch of user accounts from a legacy PHP website to a new and shiny Django-based site. A bunch of the passwords are stored as the MD5 hash output from PHP's crypt() function (see the third example there).

Given this password hash from the legacy application:

$1$f1KtBi.v$nWwBN8CP3igfC3Emo0OB8/

How might I convert it to the Django form of md5$<salt>$<hash>? The crypt() MD5 output seems to use a different alphabet than Django's MD5 support (which appears to be using a hexdigest).

Update:

There's a similar (and unanswered) question with an interesting potential solution to convert the PHP hash to a base-16 encoding, but based on some initial poking, it doesn't seem to produce a usable MD5 hexdigest. :(

Concrete example:

A concrete example might help.

Given:

  • a password of foo
  • a salt of $1$aofigrjlh

In PHP, crypt('foo', '$1$aofigrjlh') produces a hash of $1$aofigrjl$xLnO.D8x064D1kDUKWwbX..

crypt() is operating in MD5 mode, but it's some wacky Danish translation of the MD5 algorithm (Update: It's MD5-Crypt). Since Python is a Dutch-derived language, Python's crypt module only supports the DES-style of hashing.

In Python, I need to be able to reproduce that hash, or some regular derivation of it, given the original password and salt.

3
Django says it supports crypt(). Perhaps give it a try to see if you can mangle the salt and encoding to get them to match. But I would be wary of any framework like that which enforces what you do like that (so that you can't use requirements driven methods)...ircmaxell
Python's crypt is the equivalent to PHP's crypt() in DES mode, not MD5.David Eyk
Have you tried putting it in the form md5$$1$f1KtBi.v$$nWwBN8CP3igfC3Emo0OB8/ and testing it against a known password? Or is that that the salt and hash are in hexadecimal in the Django format?bcoughlan
Python's crypt is equivalent to PHP's crypt() all together. Django strips out the $1$ needed which is what upsets that. So Python supports crypt in multiple modes, but Django does not (which is a huge issue IMHO).ircmaxell
@waitinfo: that won't work due to how Django operates: check_password. It splits on $, and therefore results in the unusability of other crypto methods...ircmaxell

3 Answers

7
votes

Unfortunately, it isn't possible to convert those over to Django's format (though there is a possible route you can take that will get your hashes imported, detailed below).

Django's salted md5 algorithm uses a very simple algorithm: md5(salt + password), which is then encoded to hexidecimal.

On the other hand, the hashes output by PHP's crypt() which begin with $1$ are not simple md5 hashes. Instead, they use a password hashing algorithm known as MD5-Crypt. This is much more complex (and secure) than a simple md5 hash. There's a section in the linked page which describes the MD5-Crypt format & algorithm. There is no way to translate it into Django's format, as it doesn't offer support for the algorithm within it's code.

While Django does have code which called Python's stdlib crypt() function, the way Django mangles the hashes means there's no easy way to get a hash beginning with $1$ all the way through Django and into crypt(); and that's the only way to signal to crypt() that you want to use MD5-Crypt instead of the older DES-Crypt.


However, there is a possible route: you can monkeypatch django.contrib.auth.models.User so that it supports both the normal Django hashes, as well as the MD5-Crypt format. That way you can import the hashes unchanged. One way is to do this manually, by overriding the User.set_password and User.check_password methods.

Another alternative is to use the Passlib library, which contains a Django app that was designed to take care of all this, as well as provide cross-platform support for md5-crypt et al. (Disclaimer: I'm the author of that library). Unfortunately that Django plugin is undocumented, because I haven't tested it much outside of my own django deploys... though it works fine for them :) (There is some beta documentation in the source) edit: As of Passlib 1.6, this is extension is now officially released and documented.

In order to use it, install passlib, and add passlib.ext.django to your list of installed apps. Then, within settings.py, add the following:

PASSLIB_CONFIG = """
[passlib]
schemes =
    md5_crypt,
    django_salted_sha1, django_salted_md5,
    django_des_crypt, hex_md5,
    django_disabled

default = md5_crypt

deprecated = django_des_crypt, hex_md5
"""

This will override User.set_password and User.check_password to use Passlib instead of the builtin code. The configuration string above configures passlib to mimic Django's builtin hashes, but then adds support for md5_crypt, so your hashes should then be accepted as-is.

2
votes

Check out passlib.hash.md5_crypt, by the awesome passlib project.

1
votes

I am in process of migrating from Wordpress 2.8 to Django 1.8. As I found out Wordpress 2.8 (and probably future versions as well) stores password in MD5 crypto format (phpass library). I tried passlib extension for Django 1.8 but it didn't work for me. So I ended up writing custom hasher with MD5 crypto algorithm.

NOTE: During migration add "md5_crypt" to password hash (user_pass field)

I added MD5CryptPasswordHasher to the top of the list to make it default (in order not to mix up different hashing algorithms, what if I will migrate once again to another platform?) but it can be added to the bottom of the list if one just want to add support for the algorithm for existing users but force new users to migrate to PBKDF2PasswordHasher hasher or other.

settings.py

PASSWORD_HASHERS = (
    'your_project_name.hashers.MD5CryptPasswordHasher',
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.BCryptPasswordHasher',
    'django.contrib.auth.hashers.SHA1PasswordHasher',
    'django.contrib.auth.hashers.MD5PasswordHasher',
    'django.contrib.auth.hashers.UnsaltedSHA1PasswordHasher',
    'django.contrib.auth.hashers.UnsaltedMD5PasswordHasher',
    'django.contrib.auth.hashers.CryptPasswordHasher',
)

hashers.py

import math
import hashlib
from django.contrib.auth.hashers import BasePasswordHasher
from django.utils.crypto import get_random_string
from django.contrib.auth.hashers import mask_hash
from collections import OrderedDict
from django.utils.translation import ugettext, ugettext_lazy as _

itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
def encode64(inp, count):
    outp = ''
    cur = 0
    while cur < count:
        value = inp[cur]
        cur += 1
        outp += itoa64[value & 0x3f]
        if cur < count:
            value |= (inp[cur] << 8)
        outp += itoa64[(value >> 6) & 0x3f]
        if cur >= count:
            break
        cur += 1
        if cur < count:
            value |= (inp[cur] << 16)
        outp += itoa64[(value >> 12) & 0x3f]
        if cur >= count:
            break
        cur += 1
        outp += itoa64[(value >> 18) & 0x3f]
    return outp.encode()

def crypt_private(pw, algorithm, code, salt, iterations):
    header = "%s$%s$%s%s" % (algorithm, code, itoa64[int(math.log(iterations, 2))], salt)
    pw = pw.encode()
    salt = salt.encode()
    hx = hashlib.md5(salt + pw).digest()
    while iterations:
        hx = hashlib.md5(hx + pw).digest()
        iterations -= 1
    return header + encode64(hx, 16).decode()


def get_md5_crypto_hash_params(encoded):
    algorithm, code, rest = encoded.split('$', 2)
    count_log2 = itoa64.find(rest[0])
    iterations = 1 << count_log2
    salt = rest[1:9]
    return (algorithm, salt, iterations)

class MD5CryptPasswordHasher(BasePasswordHasher):
    """
    The Salted MD5 Crypt password hashing algorithm that is used by Wordpress 2.8
    WARNING!
    The algorithm is not robust enough to handle any kind of MD5 crypt variations
    It was stripped and refactored based on passlib implementations especially for Wordpress 2.8 format
    """
    algorithm = "md5_crypt"

    iterations = 8192
    code = "P" # Modular Crypt prefix for phpass
    salt_len = 8

    def salt(self):
        return get_random_string(salt_len)

    def encode(self, password, salt):
        assert password is not None
        assert salt != ''
        return crypt_private(password, self.algorithm, self.code, salt, self.iterations)
        pass

    def verify(self, password, encoded):
        algorithm, salt, iterations = get_md5_crypto_hash_params(encoded)
        assert algorithm == self.algorithm
        return crypt_private(password, algorithm, self.code, salt, iterations) == encoded


    def safe_summary(self, encoded):
        algorithm, code, rest = encoded.split('$', 2)
        salt = rest[1:9]
        hash = rest[9:]
        assert algorithm == self.algorithm
        return OrderedDict([
            (_('algorithm'), algorithm),
            (_('salt'), mask_hash(salt, show=2)),
            (_('hash'), mask_hash(hash)),
        ])