8

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.

Community
  • 1
  • 1
David Eyk
  • 12,171
  • 11
  • 63
  • 103
  • 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 Sep 07 '11 at 18:25
  • Python's `crypt` is the equivalent to PHP's `crypt()` in DES mode, not MD5. – David Eyk Sep 07 '11 at 18:27
  • 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 Sep 07 '11 at 18:29
  • 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 Sep 07 '11 at 18:34
  • @waitinfo: that won't work due to how Django operates: [check_password](https://code.djangoproject.com/browser/django/trunk/django/contrib/auth/utils.py#L47). It splits on `$`, and therefore results in the unusability of other crypto methods... – ircmaxell Sep 07 '11 at 18:35
  • @ircmaxell Python's `crypt()` only supports DES mode, as best as I can tell. Given a password of `foo`, salt of `$1$aofigrjlh` and a resulting PHP crypt() hash of `$1$aofigrjl$xLnO.D8x064D1kDUKWwbX.`, in Python, `crypt.crypt('foo', '$1$aofigrjl$xLnO.D8x064D1kDUKWwbX.') == crypt.crypt('foo', '$1') == '$1ijcBs1tr2sw'`. This is not an MD5 hash, and not the same – David Eyk Sep 07 '11 at 18:45

3 Answers3

7

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.

Eli Collins
  • 8,375
  • 2
  • 34
  • 38
  • One minor quibble: the `PASSLIB_CONTEXT` you suggests makes `md5_crypt` the default, which isn't necessarily what I'm trying to do. I just want to add support for the scheem, not switch to it. – David Eyk Sep 09 '11 at 18:05
  • Ah. In that case you can change it to be `default = django_salted_sha1`. If you want, you can also add `md5_crypt` to the list of deprecated hashes, and users will have their hash migrated to the default as they log in. (Also, security wise, I'd recommend adding `sha512_crypt` to the list of schemes, and as default, as it's more secure than md5-crypt or any of django's hashes... it's just non-standard for django). The options in that string correspond to constructor options which are details [here](http://packages.python.org/passlib/lib/passlib.context-options.html#cryptcontext-options). – Eli Collins Sep 09 '11 at 18:31
  • Very nice. I like the idea of migrating passwords to stronger hashes, as I have some other legacy accounts using DES-Crypt. – David Eyk Sep 09 '11 at 18:36
2

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

orip
  • 73,323
  • 21
  • 116
  • 148
1

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)),
        ])
Alex
  • 111
  • 1
  • 6