# Copyright (C) 2007-2011 by the Free Software Foundation, Inc.
#
# This file is part of GNU Mailman.
#
# GNU Mailman is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# GNU Mailman 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 General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see .
"""Password hashing and verification schemes.
Represents passwords using RFC 2307 syntax (as best we can tell).
"""
from __future__ import absolute_import, unicode_literals
__metaclass__ = type
__all__ = [
'Schemes',
'check_response',
'encrypt_password',
'make_secret',
'make_user_friendly_password',
]
import os
import re
import hmac
import random
import hashlib
from array import array
from base64 import urlsafe_b64decode as decode
from base64 import urlsafe_b64encode as encode
from flufl.enum import Enum
from itertools import chain, product
from string import ascii_lowercase
from mailman.config import config
from mailman.core import errors
SALT_LENGTH = 20 # bytes
ITERATIONS = 2000
EMPTYSTRING = ''
SCHEME_RE = r'{(?P[^}]+?)}(?P.*)'
# pylint: disable-msg=W0232
class PasswordScheme:
"""Password scheme base class."""
TAG = b''
@staticmethod
def make_secret(password):
"""Return the hashed password.
:param password: The clear text password.
:type password: string
:return: The encrypted password.
:rtype: string
"""
raise NotImplementedError
@staticmethod
def check_response(challenge, response):
"""Check a response against a challenge.
It is expected that the scheme specifier prefix is already stripped
from the response string.
:param challenge: The challenge.
:type challenge: string
:param response: The response.
:type response: string
:return: True if the response matches the challenge.
:rtype: bool
"""
raise NotImplementedError
class NoPasswordScheme(PasswordScheme):
"""A password scheme without passwords."""
TAG = b'NONE'
@staticmethod
def make_secret(password):
"""See `PasswordScheme`."""
return b''
# pylint: disable-msg=W0613
@staticmethod
def check_response(challenge, response):
"""See `PasswordScheme`."""
return False
class ClearTextPasswordScheme(PasswordScheme):
"""A password scheme that stores clear text passwords."""
TAG = b'CLEARTEXT'
@staticmethod
def make_secret(password):
"""See `PasswordScheme`."""
return password
@staticmethod
def check_response(challenge, response):
"""See `PasswordScheme`."""
return challenge == response
class SHAPasswordScheme(PasswordScheme):
"""A password scheme that encodes the password using SHA1."""
TAG = b'SHA'
@staticmethod
def make_secret(password):
"""See `PasswordScheme`."""
h = hashlib.sha1(password)
return encode(h.digest())
@staticmethod
def check_response(challenge, response):
"""See `PasswordScheme`."""
h = hashlib.sha1(response)
return challenge == encode(h.digest())
class SSHAPasswordScheme(PasswordScheme):
"""A password scheme that encodes the password using salted SHA1."""
TAG = b'SSHA'
@staticmethod
def make_secret(password):
"""See `PasswordScheme`."""
salt = os.urandom(SALT_LENGTH)
h = hashlib.sha1(password)
h.update(salt)
return encode(h.digest() + salt)
@staticmethod
def check_response(challenge, response):
"""See `PasswordScheme`."""
# Get the salt from the challenge
challenge_bytes = decode(challenge)
digest = challenge_bytes[:20]
salt = challenge_bytes[20:]
h = hashlib.sha1(response)
h.update(salt)
return digest == h.digest()
# Basic algorithm given by Bob Fleck
class PBKDF2PasswordScheme(PasswordScheme):
"""RFC 2989 password encoding scheme."""
# This is a bit nasty if we wanted a different prf or iterations. OTOH,
# we really have no clue what the standard LDAP-ish specification for
# those options is.
TAG = b'PBKDF2 SHA {0}'.format(ITERATIONS)
@staticmethod
def _pbkdf2(password, salt, iterations):
"""From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
case. Output of 20 bytes means always exactly one block to handle,
and a constant block counter appended to the salt in the initial hmac
update.
"""
h = hmac.new(password, None, hashlib.sha1)
prf = h.copy()
prf.update(salt + b'\x00\x00\x00\x01')
T = U = array(b'i', prf.digest())
while iterations:
prf = h.copy()
prf.update(U.tostring())
U = array(b'i', prf.digest())
T = array(b'i', (t ^ u for t, u in zip(T, U)))
iterations -= 1
return T.tostring()
@staticmethod
def make_secret(password):
"""See `PasswordScheme`.
From RFC2898 sec. 5.2. Simplified to handle only 20 byte output
case. Output of 20 bytes means always exactly one block to handle,
and a constant block counter appended to the salt in the initial hmac
update.
"""
salt = os.urandom(SALT_LENGTH)
digest = PBKDF2PasswordScheme._pbkdf2(password, salt, ITERATIONS)
derived_key = encode(digest + salt)
return derived_key
# pylint: disable-msg=W0221
@staticmethod
def check_response(challenge, response, prf, iterations):
"""See `PasswordScheme`."""
# Decode the challenge to get the number of iterations and salt. We
# don't support anything but SHA PRF.
if prf.lower() != b'sha':
return False
try:
iterations = int(iterations)
except (ValueError, TypeError):
return False
challenge_bytes = decode(challenge)
digest = challenge_bytes[:20]
salt = challenge_bytes[20:]
key = PBKDF2PasswordScheme._pbkdf2(response, salt, iterations)
return digest == key
class Schemes(Enum):
"""List of password schemes."""
# no_scheme is deliberately ugly because no one should be using it. Yes,
# this makes cleartext inconsistent, but that's a common enough
# terminology to justify the missing underscore.
no_scheme = 1
cleartext = 2
sha = 3
ssha = 4
pbkdf2 = 5
_SCHEMES_BY_ENUM = {
Schemes.no_scheme : NoPasswordScheme,
Schemes.cleartext : ClearTextPasswordScheme,
Schemes.sha : SHAPasswordScheme,
Schemes.ssha : SSHAPasswordScheme,
Schemes.pbkdf2 : PBKDF2PasswordScheme,
}
# Some scheme tags have arguments, but the key for this dictionary should just
# be the lowercased scheme name.
_SCHEMES_BY_TAG = dict((_SCHEMES_BY_ENUM[e].TAG.split(' ')[0].lower(), e)
for e in _SCHEMES_BY_ENUM)
_DEFAULT_SCHEME = NoPasswordScheme
def make_secret(password, scheme=None):
"""Encrypt a password.
:param password: The clear text password.
:type password: string
:param scheme: The password scheme name.
:type scheme: string
:return: The encrypted password.
:rtype: string
"""
# The hash algorithms operate on bytes not strings. The password argument
# as provided here by the client will be a string (in Python 2 either
# unicode or 8-bit, in Python 3 always unicode). We need to encode this
# string into a byte array, and the way to spell that in Python 2 is to
# encode the string to utf-8. The returned secret is a string, so it must
# be a unicode.
if isinstance(password, unicode):
password = password.encode('utf-8')
scheme_class = _SCHEMES_BY_ENUM.get(scheme)
if not scheme_class:
raise errors.BadPasswordSchemeError(scheme)
secret = scheme_class.make_secret(password)
return b'{{{0}}}{1}'.format(scheme_class.TAG, secret)
def check_response(challenge, response):
"""Check a response against a challenge.
:param challenge: The challenge.
:type challenge: string
:param response: The response.
:type response: string
:return: True if the response matches the challenge.
:rtype: bool
"""
mo = re.match(SCHEME_RE, challenge, re.IGNORECASE)
if not mo:
return False
# See above for why we convert here. However because we should have
# generated the challenge, we assume that it is already a byte string.
if isinstance(response, unicode):
response = response.encode('utf-8')
scheme_group, rest_group = mo.group('scheme', 'rest')
scheme_parts = scheme_group.split()
scheme = scheme_parts[0].lower()
scheme_enum = _SCHEMES_BY_TAG.get(scheme, _DEFAULT_SCHEME)
scheme_class = _SCHEMES_BY_ENUM[scheme_enum]
if isinstance(rest_group, unicode):
rest_group = rest_group.encode('utf-8')
return scheme_class.check_response(rest_group, response, *scheme_parts[1:])
def lookup_scheme(scheme_name):
"""Look up a password scheme.
:param scheme_name: The password scheme name.
:type scheme_name: string
:return: Password scheme class.
:rtype: `PasswordScheme`
"""
return _SCHEMES_BY_TAG.get(scheme_name.lower())
def encrypt_password(password, scheme=None):
"""Return an encrypted password.
If the given password is already encrypted (i.e. it has a scheme prefix),
then the password is return unchanged. Otherwise, it is encrypted with
the given scheme or the default scheme.
:param password: The plain text or encrypted password.
:type password: string
:param scheme: The scheme enum to use for encryption. If not given, the
system default scheme is used. This can be a `Schemes` enum item, or
the scheme name as a string.
:type scheme: `Schemes` enum, or string.
:return: The encrypted password.
:rtype: bytes
"""
if not isinstance(password, (bytes, unicode)):
raise ValueError('Got {0}, expected unicode or bytes'.format(
type(password)))
if re.match(SCHEME_RE, password, re.IGNORECASE):
# Just ensure we're getting bytes back.
if isinstance(password, unicode):
return password.encode('us-ascii')
assert isinstance(password, bytes), 'Expected bytes'
return password
if scheme is None:
password_scheme = lookup_scheme(config.passwords.password_scheme)
elif scheme in Schemes:
password_scheme = scheme
else:
password_scheme = lookup_scheme(scheme)
if password_scheme is None:
raise ValueError('Bad password scheme: {0}'.format(scheme))
return make_secret(password, password_scheme)
# Password generation.
_vowels = tuple('aeiou')
_consonants = tuple(c for c in ascii_lowercase if c not in _vowels)
_syllables = tuple(x + y for (x, y) in
chain(product(_vowels, _consonants),
product(_consonants, _vowels)))
def make_user_friendly_password(length=None):
"""Make a random *user friendly* password.
Such passwords are nominally easier to pronounce and thus remember. Their
security in relationship to purely random passwords has not been
determined.
:param length: Minimum length in characters for the resulting password.
The password will always be an even number of characters. When
omitted, the system default length will be used.
:type length: int
:return: The user friendly password.
:rtype: unicode
"""
if length is None:
length = int(config.passwords.password_length)
syllables = []
while len(syllables) * 2 < length:
syllables.append(random.choice(_syllables))
return EMPTYSTRING.join(syllables)[:length]