summaryrefslogtreecommitdiff
path: root/passlib/utils/drivers.py
blob: d2dc2483f27796e3b006160ce56ce5881750366c (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
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
"""passlib.handler - code for implementing handlers, and global registry for handlers"""
#=========================================================
#imports
#=========================================================
from __future__ import with_statement
#core
import inspect
import re
import hashlib
import logging; log = logging.getLogger(__name__)
import time
import os
from warnings import warn
#site
#libs
from passlib.utils import classproperty, h64, getrandstr, rng, is_crypt_handler
#pkg
#local
__all__ = [

    #framework for implementing handlers
    'BaseHash',
    'ExtHash',
    'StaticHash',

    'BackendMixin',
        'BackendExtHash',
        'BackendStaticHash',
]

#=========================================================
#base handler
#=========================================================
class BaseHash(object):
    """helper for implementing password hash handler with minimal methods

    hash implementations should fill out the following:

        * all required class attributes: name, setting_kwds
        * classmethods genconfig() and genhash()

    many implementations will want to override the following:

        * classmethod identify() can usually be done more efficiently

    most implementations can use defaults for the following:

        * encrypt(), verify()

    note this class does not support context kwds of any type,
    since that is a rare enough requirement inside passlib.

    implemented subclasses may call cls.validate_class() to check attribute consistency
    (usually only required in unittests, etc)
    """

    #=====================================================
    #required attributes
    #=====================================================
    name = None #required by subclass
    setting_kwds = None #required by subclass
    context_kwds = ()

    #=====================================================
    #init
    #=====================================================
    @classmethod
    def validate_class(cls):
        "helper to ensure class is configured property"
        if not cls.name:
            raise AssertionError, "class must have .name attribute set"

        if cls.setting_kwds is None:
            raise AssertionError, "class must have .setting_kwds attribute set"

    #=====================================================
    #init helpers
    #=====================================================
    @classproperty
    def _has_settings(cls):
        "attr for checking if class has ANY settings, memoizes itself on first use"
        if cls.name is None:
            #otherwise this would optimize itself away prematurely
            raise RuntimeError, "_has_settings must be called on subclass only: %r" % (cls,)
        value = cls._has_settings = bool(cls.setting_kwds)
        return value

    #=====================================================
    #formatting (usually subclassed)
    #=====================================================
    @classmethod
    def identify(cls, hash):
        #NOTE: this relys on genhash throwing error for invalid hashes.
        # this approach is bad because genhash may take a long time on valid hashes,
        # so subclasses *really* should override this.
        try:
            cls.genhash('stub', hash)
            return True
        except ValueError:
            return False

    #=====================================================
    #primary interface (must be subclassed)
    #=====================================================
    @classmethod
    def genconfig(cls, **settings):
        if cls._has_settings:
            raise NotImplementedError, "%s subclass must implement genconfig()" % (cls,)
        else:
            if settings:
                raise TypeError, "%s genconfig takes no kwds" % (cls.name,)
            return None

    @classmethod
    def genhash(cls, secret, config):
        raise NotImplementedError, "%s subclass must implement genhash()" % (cls,)

    #=====================================================
    #secondary interface (rarely subclassed)
    #=====================================================
    @classmethod
    def encrypt(cls, secret, **settings):
        config = cls.genconfig(**settings)
        return cls.genhash(secret, config)

    @classmethod
    def verify(cls, secret, hash):
        if not hash:
            raise ValueError, "no hash specified"
        return hash == cls.genhash(secret, hash)

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

#=========================================================
# ExtHash
#   rounds+salt+xtra    phpass, sha256_crypt, sha512_crypt
#   rounds+salt         bcrypt, ext_des_crypt, sha1_crypt, sun_md5_crypt
#   salt only           apr_md5_crypt, des_crypt, md5_crypt
#=========================================================
class ExtHash(BaseHash):
    """helper class for implementing hash schemes

    hash implementations should fill out the following:
        * all required class attributes:
            - name, setting_kwds
            - max_salt_chars, min_salt_chars - only if salt is used
            - max_rounds, min_rounds, default_rounds - only if rounds are used
        * classmethod from_string()
        * instancemethod to_string()
        * instancemethod calc_checksum()

    many implementations will want to override the following:
        * classmethod identify() can usually be done more efficiently
        * checksum_charset, checksum_chars attributes may prove helpful for validation

    most implementations can use defaults for the following:
        * genconfig(), genhash(), encrypt(), verify(), etc
        * norm_checksum() usually only needs overriding if checksum has multiple encodings
        * salt_charset, default_salt_charset, default_salt_chars - if does not match common case

    note this class does not support context kwds of any type,
    since that is a rare enough requirement inside passlib.

    implemented subclasses may call cls.validate_class() to check attribute consistency
    (usually only required in unittests, etc)
    """

    #=========================================================
    #class attributes
    #=========================================================

    #----------------------------------------------
    #password hash api - required attributes
    #----------------------------------------------
    name = None #required by ExtHash
    setting_kwds = None #required by ExtHash
    context_kwds = ()

    #----------------------------------------------
    #checksum information
    #----------------------------------------------
    checksum_charset = None #if specified, norm_checksum() will validate this
    checksum_chars = None #if specified, norm_checksum will require this length

    #----------------------------------------------
    #salt information
    #----------------------------------------------
    max_salt_chars = None #required by ExtHash.norm_salt()

    @classproperty
    def min_salt_chars(cls):
        "min salt chars (defaults to max_salt_chars if not specified by subclass)"
        return cls.max_salt_chars

    @classproperty
    def default_salt_chars(cls):
        "default salt chars (defaults to max_salt_chars if not specified by subclass)"
        return cls.max_salt_chars

    salt_charset = h64.CHARS

    @classproperty
    def default_salt_charset(cls):
        return cls.salt_charset

    #----------------------------------------------
    #rounds information
    #----------------------------------------------
    min_rounds = 0
    max_rounds = None #required by ExtHash.norm_rounds()
    default_rounds = None #if not specified, ExtHash.norm_rounds() will require explicit rounds value every time
    rounds_cost = "linear" #common case

    #----------------------------------------------
    #misc ExtHash configuration
    #----------------------------------------------
    _strict_rounds_bounds = False #if true, always raises error if specified rounds values out of range - required by spec for some hashes
    _extra_init_settings = () #settings that ExtHash.__init__ should handle by calling norm_<key>()

    #=========================================================
    #instance attributes
    #=========================================================
    checksum = None
    salt = None
    rounds = None

    #=========================================================
    #init
    #=========================================================
    #XXX: rename strict kwd to _strict ?
    #XXX: for from_string() purposes, a strict_salt kwd to override strict, might also be useful
    def __init__(self, checksum=None, salt=None, rounds=None, strict=False, **kwds):
        self.checksum = self.norm_checksum(checksum, strict=strict)
        self.salt = self.norm_salt(salt, strict=strict)
        self.rounds = self.norm_rounds(rounds, strict=strict)
        extra = self._extra_init_settings
        if extra:
            for key in extra:
                value = kwds.pop(key, None)
                norm = getattr(self, "norm_" + key)
                value = norm(value, strict=strict)
                setattr(self, key, value)
        super(ExtHash, self).__init__(**kwds)

    @classmethod
    def validate_class(cls):
        "helper to ensure class is configured property"
        super(ExtHash, cls).validate_class()

        if any(k not in cls.setting_kwds for k in cls._extra_init_settings):
            raise AssertionError, "_extra_init_settings must be subset of setting_kwds"

        if 'salt' in cls.setting_kwds:

            if cls.min_salt_chars > cls.max_salt_chars:
                raise AssertionError, "min salt chars too large"

            if cls.default_salt_chars < cls.min_salt_chars:
                raise AssertionError, "default salt chars too small"
            if cls.default_salt_chars > cls.max_salt_chars:
                raise AssertionError, "default salt chars too large"

            if any(c not in cls.salt_charset for c in cls.default_salt_charset):
                raise AssertionError, "default salt charset not subset of salt charset"

        if 'rounds' in cls.setting_kwds:

            if cls.max_rounds is None:
                raise AssertionError, "max rounds not specified"

            if cls.min_rounds > cls.max_rounds:
                raise AssertionError, "min rounds too large"

            if cls.default_rounds is not None:
                if cls.default_rounds < cls.min_rounds:
                    raise AssertionError, "default rounds too small"
                if cls.default_rounds > cls.max_rounds:
                    raise AssertionError, "default rounds too large"

            if cls.rounds_cost not in ("linear", "log2"):
                raise AssertionError, "unknown rounds cost function"

    #=========================================================
    #init helpers
    #=========================================================

    #---------------------------------------------------------
    #internal tests for features
    #---------------------------------------------------------

    @classproperty
    def _has_salt(cls):
        "attr for checking if salts are supported, memoizes itself on first use"
        if cls is ExtHash:
            raise RuntimeError, "not allowed for ExtHash directly"
        value = cls._has_salt = 'salt' in cls.setting_kwds
        return value

    @classproperty
    def _has_rounds(cls):
        "attr for checking if variable are supported, memoizes itself on first use"
        if cls is ExtHash:
            raise RuntimeError, "not allowed for ExtHash directly"
        value = cls._has_rounds = 'rounds' in cls.setting_kwds
        return value

    #---------------------------------------------------------
    #normalization/validation helpers
    #---------------------------------------------------------
    @classmethod
    def norm_checksum(cls, checksum, strict=False):
        if checksum is None:
            return None
        cc = cls.checksum_chars
        if cc and len(checksum) != cc:
            raise ValueError, "%s checksum must be %d characters" % (cls.name, cc)
        cs = cls.checksum_charset
        if cs and any(c not in cs for c in checksum):
            raise ValueError, "invalid characters in %s checksum" % (cls.name,)
        return checksum

    @classmethod
    def norm_salt(cls, salt, strict=False):
        """helper to normalize & validate user-provided salt string

        :arg salt: salt string or ``None``
        :param strict: enable strict checking (see below); disabled by default

        :raises ValueError:

            * if ``strict=True`` and no salt is provided
            * if ``strict=True`` and salt contains greater than :attr:`max_salt_chars` characters
            * if salt contains chars that aren't in :attr:`salt_charset`.
            * if salt contains less than :attr:`min_salt_chars` characters.

        if no salt provided and ``strict=False``, a random salt is generated
        using :attr:`default_salt_chars` and :attr:`default_salt_charset`.
        if the salt is longer than :attr:`max_salt_chars` and ``strict=False``,
        the salt string is clipped to :attr:`max_salt_chars`.

        :returns:
            normalized or generated salt
        """
        if not cls._has_salt:
            #NOTE: special casing schemes which have no salt...
            if salt is not None:
                raise ValueError, "%s does not support ``salt`` parameter" % (cls.name,)
            return None

        if salt is None:
            if strict:
                raise ValueError, "no salt specified"
            return getrandstr(rng, cls.default_salt_charset, cls.default_salt_chars)

        #TODO: run salt_charset tests
        sc = cls.salt_charset
        if sc:
            for c in salt:
                if c not in sc:
                    raise ValueError, "invalid character in %s salt: %r"  % (cls.name, c)

        mn = cls.min_salt_chars
        if mn and len(salt) < mn:
            raise ValueError, "%s salt string must be at least %d characters" % (cls.name, mn)

        mx = cls.max_salt_chars
        if len(salt) > mx:
            if strict:
                raise ValueError, "%s salt string must be at most %d characters" % (cls.name, mx)
            salt = salt[:mx]

        return salt

    @classmethod
    def norm_rounds(cls, rounds, strict=False):
        """helper routine for normalizing rounds

        :arg rounds: rounds integer or ``None``
        :param strict: enable strict checking (see below); disabled by default

        :raises ValueError:

            * if rounds is ``None`` and ``strict=True``
            * if rounds is ``None`` and no :attr:`default_rounds` are specified by class.
            * if rounds is outside bounds of :attr:`min_rounds` and :attr:`max_rounds`, and ``strict=True``.

        if rounds are not specified and ``strict=False``, uses :attr:`default_rounds`.
        if rounds are outside bounds and ``strict=False``, rounds are clipped as appropriate,
        but a warning is issued.

        :returns:
            normalized rounds value
        """
        if not cls._has_rounds:
            #NOTE: special casing schemes which don't have rounds
            if rounds is not None:
                raise ValueError, "%s does not support ``rounds``" % (cls.name,)
            return None

        if rounds is None:
            if strict:
                raise ValueError, "no rounds specified"
            rounds = cls.default_rounds
            if rounds is None:
                raise ValueError, "%s rounds value must be specified explicitly" % (cls.name,)
            return rounds

        if cls._strict_rounds_bounds:
            strict = True

        mn = cls.min_rounds
        if rounds < mn:
            if strict:
                raise ValueError, "%s rounds must be >= %d" % (cls.name, mn)
            warn("%s does not allow less than %d rounds: %d" % (cls.name, mn, rounds))
            rounds = mn

        mx = cls.max_rounds
        if rounds > mx:
            if strict:
                raise ValueError, "%s rounds must be <= %d" % (cls.name, mx)
            warn("%s does not allow more than %d rounds: %d" % (cls.name, mx, rounds))
            rounds = mx

        return rounds

    #=========================================================
    #password hash api - formatting interface
    #=========================================================
    @classmethod
    def identify(cls, hash):
        #NOTE: subclasses may wish to use faster / simpler identify,
        # and raise value errors only when an invalid (but identifiable) string is parsed
        if not hash:
            return False
        try:
            cls.from_string(hash)
            return True
        except ValueError:
            return False

    @classmethod
    def from_string(cls, hash):
        "return parsed instance from hash/configuration string; raising ValueError on invalid inputs"
        raise NotImplementedError, "%s must implement from_string()" % (cls,)

    def to_string(self):
        "render instance to hash or configuration string (depending on if checksum attr is set)"
        raise NotImplementedError, "%s must implement from_string()" % (type(self),)

    ##def to_config_string(self):
    ##    "helper for generating configuration string (ignoring hash)"
    ##    chk = self.checksum
    ##    if chk:
    ##        try:
    ##            self.checksum = None
    ##            return self.to_string()
    ##        finally:
    ##            self.checksum = chk
    ##    else:
    ##        return self.to_string()

    #=========================================================
    #password hash api - primary interface (default implementation)
    #=========================================================
    @classmethod
    def genconfig(cls, **settings):
        return cls(**settings).to_string()

    @classmethod
    def genhash(cls, secret, config):
        self = cls.from_string(config)
        self.checksum = self.calc_checksum(secret)
        return self.to_string()

    def calc_checksum(self, secret):
        "given secret; calcuate and return encoded checksum portion of hash string, taking config from object state"
        raise NotImplementedError, "%s must implement calc_checksum()" % (cls,)

    #=========================================================
    #password hash api - secondary interface (default implementation)
    #=========================================================
    @classmethod
    def encrypt(cls, secret, **settings):
        self = cls(**settings)
        self.checksum = self.calc_checksum(secret)
        return self.to_string()

    @classmethod
    def verify(cls, secret, hash):
        #NOTE: classes with multiple checksum encodings (rare)
        # may wish to either override this, or override norm_checksum
        # to normalize any checksums provided by from_string()
        self = cls.from_string(hash)
        return self.checksum == self.calc_checksum(secret)

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

#=========================================================
#static - mysql_323, mysql_41, nthash, postgres_md5
#=========================================================
class StaticHash(ExtHash):
    """helper class optimized for implementing hash schemes which have NO settings whatsoever.

    the main thing this changes from ExtHash:

    * :attr:`setting_kwds` must be an empty tuple (set by class)
    * :meth:`genconfig` takes no kwds, and always returns ``None``.
    * :meth:`genhash` accepts ``config=None``.

    otherwise, this requires the same methods be implemented
    as does ExtHash.
    """
    #=========================================================
    #class attr
    #=========================================================
    setting_kwds = ()

    #=========================================================
    #init
    #=========================================================
    @classmethod
    def validate_class(cls):
        "helper to validate that class has been configured properly"
        if cls.setting_kwds:
            raise AssertionError, "StaticHash subclasses must not have any settings, perhaps you want ExtHash?"
        super(StaticHash, cls).validate_class()

    #=========================================================
    #primary interface
    #=========================================================
    @classmethod
    def genconfig(cls):
        return None

    @classmethod
    def genhash(cls, secret, config):
        if config is None:
            self = cls()
        else:
            #just to verify input is correctly formatted
            self = cls.from_string(config)
        self.checksum = self.calc_checksum(secret)
        return self.to_string()

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

#=========================================================
#helpful mixin which provides lazy-loading of different backends
#to be used for calc_checksum
#=========================================================
class BackendMixin(object):

    #NOTE: subclass must provide:
    #   * attr 'backends' containing list of known backends (top priority backend first)
    #   * attr '_has_backend_xxx' for each backend 'xxx', indicating if backend is available on system
    #   * attr '_calc_checksum_xxx' for each backend 'xxx', containing calc_checksum implementation using that backend

    _backend = None

    @classmethod
    def get_backend(cls):
        "return name of active backend"
        return cls._backend or cls.set_backend()

    @classmethod
    def has_backend(cls, name):
        "check if specified class can be loaded"
        return getattr(cls, "_has_backend_" + name)

    @classmethod
    def set_backend(cls, name=None):
        "change class to use specified backend"
        if not name or name == "default":
            if not name:
                name = cls._backend
                if name:
                    return name
            for name in cls.backends:
                if cls.has_backend(name):
                    cls.calc_checksum = getattr(cls, "_calc_checksum_" + name)
                    cls._backend = name
                    return name
            raise EnvironmentError, "no %s backends available" % (cls.name,)
        else:
            ##if name not in cls.backends:
            ##    raise ValueError, "unknown %s backend: %r" % (cls.name, name)
            if not cls.has_backend(name):
                raise ValueError, "%s backend not available: %r" % (cls.name, name)
            cls.calc_checksum = getattr(cls, "_calc_checksum_" + name)
            cls._backend = name
            return name

    def calc_checksum(self, secret):
        "stub for calc_checksum(), default backend will be selected first time stub is called"
        #backend not loaded - run detection and call replacement
        assert not self._backend, "set_backend() failed to replace lazy loader"
        self.set_backend()
        assert self._backend, "set_backend() failed to load a default backend"
        return self.calc_checksum(secret)

class BackendExtHash(BackendMixin, ExtHash):
    pass

class BackendStaticHash(BackendMixin, StaticHash):
    pass

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