summaryrefslogtreecommitdiff
path: root/passlib/handlers/bcrypt.py
blob: 42f0eca1b0e6e7cf687c1e78d2acec3a48377d95 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
"""passlib.bcrypt -- implementation of OpenBSD's BCrypt algorithm.

TODO:

* support 2x and altered-2a hashes?
  http://www.openwall.com/lists/oss-security/2011/06/27/9

* deal with lack of PY3-compatibile c-ext implementation
"""
#=============================================================================
# imports
#=============================================================================
from __future__ import with_statement, absolute_import
# core
from base64 import b64encode
from hashlib import sha256
import os
import re
import logging; log = logging.getLogger(__name__)
from warnings import warn
# site
try:
    import bcrypt as _bcrypt
except ImportError: # pragma: no cover
    _bcrypt = None
try:
    from bcryptor.engine import Engine as bcryptor_engine
except ImportError: # pragma: no cover
    bcryptor_engine = None
# pkg
from passlib.exc import PasslibHashWarning
from passlib.utils import bcrypt64, safe_crypt, repeat_string, to_bytes, \
                          classproperty, rng, getrandstr, test_crypt, to_unicode
from passlib.utils.compat import bytes, b, u, uascii_to_str, unicode, str_to_uascii
import passlib.utils.handlers as uh

# local
__all__ = [
    "bcrypt",
]

#=============================================================================
# support funcs & constants
#=============================================================================
_builtin_bcrypt = None

def _load_builtin():
    global _builtin_bcrypt
    if _builtin_bcrypt is None:
        from passlib.utils._blowfish import raw_bcrypt as _builtin_bcrypt

IDENT_2 = u("$2$")
IDENT_2A = u("$2a$")
IDENT_2X = u("$2x$")
IDENT_2Y = u("$2y$")
_BNULL = b('\x00')

#=============================================================================
# handler
#=============================================================================
class bcrypt(uh.HasManyIdents, uh.HasRounds, uh.HasSalt, uh.HasManyBackends, uh.GenericHandler):
    """This class implements the BCrypt password hash, and follows the :ref:`password-hash-api`.

    It supports a fixed-length salt, and a variable number of rounds.

    The :meth:`~passlib.ifc.PasswordHash.encrypt` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods accept the following optional keywords:

    :type salt: str
    :param salt:
        Optional salt string.
        If not specified, one will be autogenerated (this is recommended).
        If specified, it must be 22 characters, drawn from the regexp range ``[./0-9A-Za-z]``.

    :type rounds: int
    :param rounds:
        Optional number of rounds to use.
        Defaults to 12, must be between 4 and 31, inclusive.
        This value is logarithmic, the actual number of iterations used will be :samp:`2**{rounds}`
        -- increasing the rounds by +1 will double the amount of time taken.

    :type ident: str
    :param ident:
        Specifies which version of the BCrypt algorithm will be used when creating a new hash.
        Typically this option is not needed, as the default (``"2a"``) is usually the correct choice.
        If specified, it must be one of the following:

        * ``"2"`` - the first revision of BCrypt, which suffers from a minor security flaw and is generally not used anymore.
        * ``"2a"`` - latest revision of the official BCrypt algorithm, and the current default.
        * ``"2y"`` - format specific to the *crypt_blowfish* BCrypt implementation,
          identical to ``"2a"`` in all but name.

    :type relaxed: bool
    :param relaxed:
        By default, providing an invalid value for one of the other
        keywords will result in a :exc:`ValueError`. If ``relaxed=True``,
        and the error can be corrected, a :exc:`~passlib.exc.PasslibHashWarning`
        will be issued instead. Correctable errors include ``rounds``
        that are too small or too large, and ``salt`` strings that are too long.

        .. versionadded:: 1.6

    .. versionchanged:: 1.6
        This class now supports ``"2y"`` hashes, and recognizes
        (but does not support) the broken ``"2x"`` hashes.
        (see the :ref:`crypt_blowfish bug <crypt-blowfish-bug>`
        for details).

    .. versionchanged:: 1.6
        Added a pure-python backend.
    """

    #===================================================================
    # class attrs
    #===================================================================
    #--GenericHandler--
    name = "bcrypt"
    setting_kwds = ("salt", "rounds", "ident")
    checksum_size = 31
    checksum_chars = bcrypt64.charmap

    #--HasManyIdents--
    default_ident = IDENT_2A
    ident_values = (IDENT_2, IDENT_2A, IDENT_2X, IDENT_2Y)
    ident_aliases = {u("2"): IDENT_2, u("2a"): IDENT_2A,  u("2y"): IDENT_2Y}

    #--HasSalt--
    min_salt_size = max_salt_size = 22
    salt_chars = bcrypt64.charmap
        # NOTE: 22nd salt char must be in bcrypt64._padinfo2[1], not full charmap

    #--HasRounds--
    default_rounds = 12 # current passlib default
    min_rounds = 4 # minimum from bcrypt specification
    max_rounds = 31 # 32-bit integer limit (since real_rounds=1<<rounds)
    rounds_cost = "log2"

    #===================================================================
    # formatting
    #===================================================================

    @classmethod
    def from_string(cls, hash):
        ident, tail = cls._parse_ident(hash)
        if ident == IDENT_2X:
            raise ValueError("crypt_blowfish's buggy '2x' hashes are not "
                             "currently supported")
        rounds_str, data = tail.split(u("$"))
        rounds = int(rounds_str)
        if rounds_str != u('%02d') % (rounds,):
            raise uh.exc.MalformedHashError(cls, "malformed cost field")
        salt, chk = data[:22], data[22:]
        return cls(
            rounds=rounds,
            salt=salt,
            checksum=chk or None,
            ident=ident,
        )

    def to_string(self):
        hash = u("%s%02d$%s%s") % (self.ident, self.rounds, self.salt,
                                   self.checksum or u(''))
        return uascii_to_str(hash)

    def _get_config(self, ident=None):
        "internal helper to prepare config string for backends"
        if ident is None:
            ident = self.ident
        if ident == IDENT_2Y:
            # none of passlib's backends suffered from crypt_blowfish's
            # buggy "2a" hash, which means we can safely implement
            # crypt_blowfish's "2y" hash by passing "2a" to the backends.
            ident = IDENT_2A
        else:
            # no backends currently support 2x, but that should have
            # been caught earlier in from_string()
            assert ident != IDENT_2X
        config = u("%s%02d$%s") % (ident, self.rounds, self.salt)
        return uascii_to_str(config)

    #===================================================================
    # specialized salt generation - fixes passlib issue 25
    #===================================================================

    @classmethod
    def _bind_needs_update(cls, **settings):
        return cls._needs_update

    @classmethod
    def _needs_update(cls, hash, secret):
        if isinstance(hash, bytes):
            hash = hash.decode("ascii")
        # check for incorrect padding bits (passlib issue 25)
        if hash.startswith(IDENT_2A) and hash[28] not in bcrypt64._padinfo2[1]:
            return True
        # TODO: try to detect incorrect $2x$ hashes using *secret*
        return False

    @classmethod
    def normhash(cls, hash):
        "helper to normalize hash, correcting any bcrypt padding bits"
        if cls.identify(hash):
            return cls.from_string(hash).to_string()
        else:
            return hash

    def _generate_salt(self, salt_size):
        # generate random salt as normal,
        # but repair last char so the padding bits always decode to zero.
        salt = super(bcrypt, self)._generate_salt(salt_size)
        return bcrypt64.repair_unused(salt)

    def _norm_salt(self, salt, **kwds):
        salt = super(bcrypt, self)._norm_salt(salt, **kwds)
        assert salt is not None, "HasSalt didn't generate new salt!"
        changed, salt = bcrypt64.check_repair_unused(salt)
        if changed:
            # FIXME: if salt was provided by user, this message won't be
            # correct. not sure if we want to throw error, or use different warning.
            warn(
                "encountered a bcrypt salt with incorrectly set padding bits; "
                "you may want to use bcrypt.normhash() "
                "to fix this; see Passlib 1.5.3 changelog.",
                PasslibHashWarning)
        return salt

    def _norm_checksum(self, checksum):
        checksum = super(bcrypt, self)._norm_checksum(checksum)
        if not checksum:
            return None
        changed, checksum = bcrypt64.check_repair_unused(checksum)
        if changed:
            warn(
                "encountered a bcrypt hash with incorrectly set padding bits; "
                "you may want to use bcrypt.normhash() "
                "to fix this; see Passlib 1.5.3 changelog.",
                PasslibHashWarning)
        return checksum

    #===================================================================
    # primary interface
    #===================================================================
    backends = ("bcrypt", "pybcrypt", "bcryptor", "os_crypt", "builtin")

    @classproperty
    def _has_backend_bcrypt(cls):
        return _bcrypt is not None and hasattr(_bcrypt, "_ffi")

    @classproperty
    def _has_backend_pybcrypt(cls):
        return _bcrypt is not None and not hasattr(_bcrypt, "_ffi")

    @classproperty
    def _has_backend_bcryptor(cls):
        return bcryptor_engine is not None

    @classproperty
    def _has_backend_builtin(cls):
        if os.environ.get("PASSLIB_BUILTIN_BCRYPT") not in ["enable","enabled"]:
            return False
        # look at it cross-eyed, and it loads itself
        _load_builtin()
        return True

    @classproperty
    def _has_backend_os_crypt(cls):
        # XXX: what to do if "2" isn't supported, but "2a" is?
        #      "2" is *very* rare, and can fake it using "2a"+repeat_string
        h1 = '$2$04$......................1O4gOrCYaqBG3o/4LnT2ykQUt1wbyju'
        h2 = '$2a$04$......................qiOQjkB8hxU8OzRhS.GhRMa4VUnkPty'
        return test_crypt("test",h1) and test_crypt("test", h2)

    @classmethod
    def _no_backends_msg(cls):
        return "no bcrypt backends available - please install py-bcrypt"

    def _calc_checksum(self, secret):
        "common backend code"
        if isinstance(secret, unicode):
            secret = secret.encode("utf-8")
        if _BNULL in secret:
            # NOTE: especially important to forbid NULLs for bcrypt, since many
            # backends (bcryptor, bcrypt) happily accept them, and then
            # silently truncate the password at first NULL they encounter!
            raise uh.exc.NullPasswordError(self)
        return self._calc_checksum_backend(secret)

    def _calc_checksum_os_crypt(self, secret):
        config = self._get_config()
        hash = safe_crypt(secret, config)
        if hash:
            assert hash.startswith(config) and len(hash) == len(config)+31
            return hash[-31:]
        else:
            # NOTE: it's unlikely any other backend will be available,
            # but checking before we bail, just in case.
            for name in self.backends:
                if name != "os_crypt" and self.has_backend(name):
                    func = getattr(self, "_calc_checksum_" + name)
                    return func(secret)
            raise uh.exc.MissingBackendError(
                "password can't be handled by os_crypt, "
                "recommend installing py-bcrypt.",
                )

    def _calc_checksum_bcrypt(self, secret):
        # bcrypt behavior:
        #   hash must be ascii bytes
        #   secret must be bytes
        #   returns bytes
        if self.ident == IDENT_2:
            # bcrypt doesn't support $2$ hashes; but we can fake $2$ behavior
            # using the $2a$ algorithm, by repeating the password until
            # it's at least 72 chars in length.
            if secret:
                secret = repeat_string(secret, 72)
            config = self._get_config(IDENT_2A)
        else:
            config = self._get_config()
        if isinstance(config, unicode):
            config = config.encode("ascii")
        hash = _bcrypt.hashpw(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        assert isinstance(hash, bytes)
        return hash[-31:].decode("ascii")

    def _calc_checksum_pybcrypt(self, secret):
        # py-bcrypt behavior:
        #   py2: unicode secret/hash encoded as ascii bytes before use,
        #        bytes taken as-is; returns ascii bytes.
        #   py3: unicode secret encoded as utf-8 bytes,
        #        hash encoded as ascii bytes, returns ascii unicode.
        config = self._get_config()
        hash = _bcrypt.hashpw(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        return str_to_uascii(hash[-31:])

    def _calc_checksum_bcryptor(self, secret):
        # bcryptor behavior:
        #   py2: unicode secret/hash encoded as ascii bytes before use,
        #        bytes taken as-is; returns ascii bytes.
        #   py3: not supported
        if self.ident == IDENT_2:
            # bcryptor doesn't support $2$ hashes; but we can fake $2$ behavior
            # using the $2a$ algorithm, by repeating the password until
            # it's at least 72 chars in length.
            if secret:
                secret = repeat_string(secret, 72)
            config = self._get_config(IDENT_2A)
        else:
            config = self._get_config()
        hash = bcryptor_engine(False).hash_key(secret, config)
        assert hash.startswith(config) and len(hash) == len(config)+31
        return str_to_uascii(hash[-31:])

    def _calc_checksum_builtin(self, secret):
        chk = _builtin_bcrypt(secret, self.ident.strip("$"),
                              self.salt.encode("ascii"), self.rounds)
        return chk.decode("ascii")

    #===================================================================
    # eoc
    #===================================================================

_UDOLLAR = u("$")

class bcrypt_sha256(bcrypt):
    """This class implements a composition of BCrypt+SHA256, and follows the :ref:`password-hash-api`.

    It supports a fixed-length salt, and a variable number of rounds.

    The :meth:`~passlib.ifc.PasswordHash.encrypt` and :meth:`~passlib.ifc.PasswordHash.genconfig` methods accept
    all the same optional keywords as the base :class:`bcrypt` hash.

    .. versionadded:: 1.6.2
    """
    name = "bcrypt_sha256"

    # this is locked at 2a for now.
    ident_values = (IDENT_2A,)

    # sample hash:
    # $bcrypt-sha256$2a,6$/3OeRpbOf8/l6nPPRdZPp.$nRiyYqPobEZGdNRBWihQhiFDh1ws1tu
    # $bcrypt-sha256$           -- prefix/identifier
    # 2a                        -- bcrypt variant
    # ,                         -- field separator
    # 6                         -- bcrypt work factor
    # $                         -- section separator
    # /3OeRpbOf8/l6nPPRdZPp.    -- salt
    # $                         -- section separator
    # nRiyYqPobEZGdNRBWihQhiFDh1ws1tu  -- digest

    # XXX: we can't use .ident attr due to bcrypt code using it.
    #      working around that via prefix.
    prefix = u('$bcrypt-sha256$')

    _hash_re = re.compile(r"""
        ^
        [$]bcrypt-sha256
        [$](?P<variant>[a-z0-9]+)
        ,(?P<rounds>\d{1,2})
        [$](?P<salt>[^$]{22})
        ([$](?P<digest>.{31}))?
        $
        """, re.X)

    @classmethod
    def identify(cls, hash):
        hash = uh.to_unicode_for_identify(hash)
        if not hash:
            return False
        return hash.startswith(cls.prefix)

    @classmethod
    def from_string(cls, hash):
        hash = to_unicode(hash, "ascii", "hash")
        if not hash.startswith(cls.prefix):
            raise uh.exc.InvalidHashError(cls)
        m = cls._hash_re.match(hash)
        if not m:
            raise uh.exc.MalformedHashError(cls)
        rounds = m.group("rounds")
        if rounds.startswith(uh._UZERO) and rounds != uh._UZERO:
            raise uh.exc.ZeroPaddedRoundsError(cls)
        return cls(ident=m.group("variant"),
                   rounds=int(rounds),
                   salt=m.group("salt"),
                   checksum=m.group("digest"),
                   )

    def to_string(self):
        hash = u("%s%s,%d$%s") % (self.prefix, self.ident.strip(_UDOLLAR),
                                  self.rounds, self.salt)
        if self.checksum:
            hash = u("%s$%s") % (hash, self.checksum)
        return uascii_to_str(hash)

    def _calc_checksum(self, secret):
        # NOTE: this bypasses bcrypt's _calc_checksum,
        #       so has to take care of all it's issues, such as secret encoding.
        if isinstance(secret, unicode):
            secret = secret.encode("utf-8")
        # NOTE: can't use digest directly, since bcrypt stops at first NULL.
        # NOTE: bcrypt doesn't fully mix entropy for bytes 55-72 of password
        #       (XXX: citation needed), so we don't want key to be > 55 bytes.
        #       thus, have to use base64 (44 bytes) rather than hex (64 bytes).
        key = b64encode(sha256(secret).digest())
        return self._calc_checksum_backend(key)

    # patch set_backend so it modifies bcrypt class, not this one...
    # else it would clobber our _calc_checksum() wrapper above.
    @classmethod
    def set_backend(cls, *args, **kwds):
        return bcrypt.set_backend(*args, **kwds)

#=============================================================================
# eof
#=============================================================================