312 lines
10 KiB
Python
312 lines
10 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-2019 Entr'ouvert
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify it
|
|
# under the terms of the GNU Affero General Public License as published
|
|
# by the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import base64
|
|
import hashlib
|
|
import math
|
|
from binascii import hexlify, unhexlify
|
|
from collections import OrderedDict
|
|
|
|
from django.contrib.auth import hashers
|
|
from django.contrib.auth.hashers import make_password
|
|
from django.utils.crypto import constant_time_compare
|
|
from django.utils.encoding import force_bytes, force_text
|
|
from django.utils.translation import ugettext_noop as _
|
|
|
|
|
|
class Drupal7PasswordHasher(hashers.BasePasswordHasher):
|
|
"""
|
|
Secure password hashing using the algorithm used by Drupal 7 (recommended)
|
|
"""
|
|
|
|
algorithm = "drupal7_sha512"
|
|
iterations = 10000
|
|
digest = hashlib.sha512
|
|
alphabet = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
|
|
def atoi64(self, v):
|
|
return self.alphabet.find(v)
|
|
|
|
def i64toa(self, v):
|
|
return self.alphabet[v]
|
|
|
|
def b64encode(self, v):
|
|
out = ''
|
|
count = len(v)
|
|
i = 0
|
|
while i < count:
|
|
value = v[i]
|
|
i += 1
|
|
out += self.i64toa(value & 0x3F)
|
|
if i < count:
|
|
value |= v[i] << 8
|
|
out += self.i64toa((value >> 6) & 0x3F)
|
|
if i == count:
|
|
break
|
|
i += 1
|
|
if i < count:
|
|
value |= v[i] << 16
|
|
out += self.i64toa((value >> 12) & 0x3F)
|
|
if i == count:
|
|
break
|
|
i += 1
|
|
out += self.i64toa((value >> 18) & 0x3F)
|
|
return out
|
|
|
|
def from_drupal(self, encoded):
|
|
ident, log_count, salt, h = encoded[:3], encoded[3], encoded[4:12], encoded[12:]
|
|
if ident != '$S$':
|
|
raise ValueError('Not a Drupal7 SHA-512 hashed password')
|
|
count = 1 << self.atoi64(log_count)
|
|
return '%s$%s$%s$%s' % (self.algorithm, count, salt, h)
|
|
|
|
def to_drupal(self, encoded):
|
|
algo, count, salt, h = encoded.split('$', 3)
|
|
count = self.i64toa(math.ceil(math.log(int(count), 2)))
|
|
return '$S$%s%s%s' % (count, salt, h)
|
|
|
|
def encode(self, password, salt, iterations):
|
|
assert password
|
|
assert salt and '$' not in salt
|
|
h = salt.encode()
|
|
password = password.encode()
|
|
for i in range(iterations + 1):
|
|
h = self.digest(h + password).digest()
|
|
return "%s$%d$%s$%s" % (self.algorithm, iterations, salt, self.b64encode(h)[:43])
|
|
|
|
def verify(self, password, encoded):
|
|
algorithm, iterations, salt, hash = encoded.split('$', 3)
|
|
assert algorithm == self.algorithm
|
|
encoded_2 = self.encode(password, salt, int(iterations))
|
|
return constant_time_compare(encoded, encoded_2)
|
|
|
|
def safe_summary(self, encoded):
|
|
algorithm, iterations, salt, hash = encoded.split('$', 3)
|
|
assert algorithm == self.algorithm
|
|
return OrderedDict(
|
|
[
|
|
(_('algorithm'), algorithm),
|
|
(_('iterations'), iterations),
|
|
(_('salt'), hashers.mask_hash(salt)),
|
|
(_('hash'), hashers.mask_hash(hash)),
|
|
]
|
|
)
|
|
|
|
|
|
class CommonPasswordHasher(hashers.BasePasswordHasher):
|
|
"""
|
|
The Salted MD5 password hashing algorithm (not recommended)
|
|
"""
|
|
|
|
algorithm = None
|
|
digest = None
|
|
|
|
def encode(self, password, salt):
|
|
assert password
|
|
assert '$' not in salt
|
|
hash = self.digest(force_bytes(salt + password)).hexdigest()
|
|
return "%s$%s$%s" % (self.algorithm, salt, hash)
|
|
|
|
def verify(self, password, encoded):
|
|
algorithm, salt, hash = encoded.split('$', 2)
|
|
assert algorithm == self.algorithm
|
|
encoded_2 = self.encode(password, salt)
|
|
return constant_time_compare(encoded, encoded_2)
|
|
|
|
def safe_summary(self, encoded):
|
|
algorithm, salt, hash = encoded.split('$', 2)
|
|
assert algorithm == self.algorithm
|
|
return OrderedDict(
|
|
[
|
|
(_('algorithm'), algorithm),
|
|
(_('salt'), hashers.mask_hash(salt, show=2)),
|
|
(_('hash'), hashers.mask_hash(hash)),
|
|
]
|
|
)
|
|
|
|
|
|
OPENLDAP_ALGO_MAPPING = {
|
|
'SHA': ('sha-oldap', 0, True),
|
|
'SSHA': ('ssha-oldap', 20, True),
|
|
'MD5': ('md5-oldap', 0, True),
|
|
'SMD5': ('md5-oldap', 16, True),
|
|
}
|
|
|
|
|
|
def olap_password_to_dj(password):
|
|
'''Convert an LDAP password for Django use eventually hashed'''
|
|
if password[0] == '{' and '}' in password:
|
|
algo = password[1:].split('}')[0].upper()
|
|
if algo not in OPENLDAP_ALGO_MAPPING:
|
|
raise ValueError('unknown algorithm %r' % algo)
|
|
password = password[1:].split('}')[1]
|
|
try:
|
|
password = base64.b64decode(password)
|
|
except ValueError:
|
|
raise ValueError('unable to decode base64 hash %r' % password)
|
|
algo_name, salt_offset, hex_encode = OPENLDAP_ALGO_MAPPING[algo]
|
|
salt, password = (password[salt_offset:], password[:salt_offset]) if salt_offset else ('', password)
|
|
if hex_encode:
|
|
password = force_text(hexlify(password), encoding='ascii')
|
|
salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
|
|
return '%s$%s$%s' % (algo_name, salt, password)
|
|
else:
|
|
return make_password(password)
|
|
|
|
|
|
class OpenLDAPPasswordHasher(CommonPasswordHasher):
|
|
def encode(self, password, salt):
|
|
assert password
|
|
assert b'$' not in salt
|
|
hash = self.digest(force_bytes(password + salt)).hexdigest()
|
|
salt = force_text(hexlify(salt), encoding='ascii')
|
|
return "%s$%s$%s" % (self.algorithm, salt, hash)
|
|
|
|
def verify(self, password, encoded):
|
|
algorithm, salt, hash = encoded.split('$', 2)
|
|
hash = unhexlify(hash)
|
|
salt = unhexlify(salt)
|
|
assert algorithm == self.algorithm
|
|
encoded_2 = self.encode(force_bytes(password), salt)
|
|
return constant_time_compare(encoded, encoded_2)
|
|
|
|
|
|
class SHA256PasswordHasher(CommonPasswordHasher):
|
|
algorithm = 'sha256'
|
|
digest = hashlib.sha256
|
|
|
|
|
|
class SSHA1PasswordHasher(OpenLDAPPasswordHasher):
|
|
algorithm = 'ssha-oldap'
|
|
digest = hashlib.sha1
|
|
|
|
|
|
class SMD5PasswordHasher(OpenLDAPPasswordHasher):
|
|
algorithm = 'smd5-oldap'
|
|
digest = hashlib.md5
|
|
|
|
|
|
class SHA1OLDAPPasswordHasher(OpenLDAPPasswordHasher):
|
|
algorithm = 'sha-oldap'
|
|
digest = hashlib.sha1
|
|
|
|
def salt(self):
|
|
return ''
|
|
|
|
|
|
class MD5OLDAPPasswordHasher(OpenLDAPPasswordHasher):
|
|
algorithm = 'md5-oldap'
|
|
digest = hashlib.md5
|
|
|
|
def salt(self):
|
|
return ''
|
|
|
|
|
|
class JoomlaPasswordHasher(CommonPasswordHasher):
|
|
algorithm = 'joomla'
|
|
digest = hashlib.md5
|
|
|
|
def encode(self, password, salt):
|
|
assert password
|
|
assert b'$' not in salt
|
|
hash = self.digest(force_bytes(password) + salt).hexdigest()
|
|
salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
|
|
return "%s$md5$%s$%s" % (self.algorithm, salt, hash)
|
|
|
|
def verify(self, password, encoded):
|
|
algorithm, subalgo, salt, hash = encoded.split('$', 3)
|
|
salt = unhexlify(salt)
|
|
if algorithm != self.algorithm:
|
|
raise ValueError('not a joomla encoded password')
|
|
encoded_2 = self.encode(password, salt)
|
|
return constant_time_compare(encoded, encoded_2)
|
|
|
|
@classmethod
|
|
def from_joomla(cls, encoded):
|
|
if encoded.startswith('$P$'):
|
|
raise NotImplementedError
|
|
elif encoded.startswith('$'):
|
|
raise NotImplementedError
|
|
elif encoded.startswith('{SHA256}'):
|
|
raise NotImplementedError
|
|
else:
|
|
if ':' in encoded:
|
|
h, salt = encoded.split(':', 1)
|
|
else:
|
|
h, salt = encoded, ''
|
|
salt = force_text(hexlify(force_bytes(salt)), encoding='ascii')
|
|
return '%s$md5$%s$%s' % (cls.algorithm, salt, h)
|
|
|
|
@classmethod
|
|
def to_joomla(cls, encoded):
|
|
algorithm, subalgo, salt, _hash = encoded.split('$', 4)
|
|
if algorithm != cls.algorithm:
|
|
raise ValueError('not a joomla encoded password')
|
|
if subalgo != 'md5':
|
|
raise NotImplementedError
|
|
if salt:
|
|
return '%s:%s' % (_hash, force_text(unhexlify(force_bytes(salt))))
|
|
else:
|
|
return _hash
|
|
|
|
|
|
class PloneSHA1PasswordHasher(hashers.SHA1PasswordHasher):
|
|
# from https://www.fourdigits.nl/blog/converting-plone-data-to-django/
|
|
"""
|
|
The SHA1 password hashing algorithm used by Plone.
|
|
|
|
Plone uses `password + salt`, Django has `salt + password`.
|
|
"""
|
|
|
|
algorithm = "plonesha1"
|
|
_prefix = '{SSHA}'
|
|
|
|
def encode(self, password, salt):
|
|
"""Encode a plain text password into a plonesha1 style hash."""
|
|
assert password is not None
|
|
assert salt
|
|
password = force_bytes(password)
|
|
salt = force_bytes(salt)
|
|
|
|
hashed = base64.b64encode(hashlib.sha1(password + salt).digest() + salt)
|
|
return "%s$%s%s" % (self.algorithm, self._prefix, force_text(hashed))
|
|
|
|
def verify(self, password, encoded):
|
|
"""Verify the given password against the encoded string."""
|
|
algorithm, data = encoded.split('$', 1)
|
|
assert algorithm == self.algorithm
|
|
|
|
# throw away the prefix
|
|
if data.startswith(self._prefix):
|
|
data = data[len(self._prefix) :]
|
|
|
|
# extract salt from encoded data
|
|
intermediate = base64.b64decode(data)
|
|
salt = intermediate[20:].strip()
|
|
|
|
password_encoded = self.encode(password, salt)
|
|
return constant_time_compare(password_encoded, encoded)
|
|
|
|
def safe_summary(self, encoded):
|
|
algorithm, hash = encoded.split('$', 1)
|
|
assert algorithm == self.algorithm
|
|
return OrderedDict(
|
|
[
|
|
(_('algorithm'), algorithm),
|
|
(_('hash'), hashers.mask_hash(hash)),
|
|
]
|
|
)
|