codekingpro/portable-devtools
114k
1"""passlib.pwd -- password generation helpers"""2#=============================================================================3# imports4#=============================================================================5from __future__ import absolute_import, division, print_function, unicode_literals6# core7import codecs8from collections import defaultdict9try:10 from collections.abc import MutableMapping11except ImportError:12 # py2 compat13 from collections import MutableMapping14from math import ceil, log as logf15import logging; log = logging.getLogger(__name__)16import pkg_resources17import os18# site19# pkg20from passlib import exc21from passlib.utils.compat import PY2, irange, itervalues, int_types22from passlib.utils import rng, getrandstr, to_unicode23from passlib.utils.decor import memoized_property24# local25__all__ = [26 "genword", "default_charsets",27 "genphrase", "default_wordsets",28]29 30#=============================================================================31# constants32#=============================================================================33 34# XXX: rename / publically document this map?35entropy_aliases = dict(36 # barest protection from throttled online attack37 unsafe=12,38 39 # some protection from unthrottled online attack40 weak=24,41 42 # some protection from offline attacks43 fair=36,44 45 # reasonable protection from offline attacks46 strong=48,47 48 # very good protection from offline attacks49 secure=60,50)51 52#=============================================================================53# internal helpers54#=============================================================================55 56def _superclasses(obj, cls):57 """return remaining classes in object's MRO after cls"""58 mro = type(obj).__mro__59 return mro[mro.index(cls)+1:]60 61 62def _self_info_rate(source):63 """64 returns 'rate of self-information' --65 i.e. average (per-symbol) entropy of the sequence **source**,66 where probability of a given symbol occurring is calculated based on67 the number of occurrences within the sequence itself.68 69 if all elements of the source are unique, this should equal ``log(len(source), 2)``.70 71 :arg source:72 iterable containing 0+ symbols73 (e.g. list of strings or ints, string of characters, etc).74 75 :returns:76 float bits of entropy77 """78 try:79 size = len(source)80 except TypeError:81 # if len() doesn't work, calculate size by summing counts later82 size = None83 counts = defaultdict(int)84 for char in source:85 counts[char] += 186 if size is None:87 values = counts.values()88 size = sum(values)89 else:90 values = itervalues(counts)91 if not size:92 return 093 # NOTE: the following performs ``- sum(value / size * logf(value / size, 2) for value in values)``,94 # it just does so with as much pulled out of the sum() loop as possible...95 return logf(size, 2) - sum(value * logf(value, 2) for value in values) / size96 97 98# def _total_self_info(source):99# """100# return total self-entropy of a sequence101# (the average entropy per symbol * size of sequence)102# """103# return _self_info_rate(source) * len(source)104 105 106def _open_asset_path(path, encoding=None):107 """108 :param asset_path:109 string containing absolute path to file,110 or package-relative path using format111 ``"python.module:relative/file/path"``.112 113 :returns:114 filehandle opened in 'rb' mode115 (unless encoding explicitly specified)116 """117 if encoding:118 return codecs.getreader(encoding)(_open_asset_path(path))119 if os.path.isabs(path):120 return open(path, "rb")121 package, sep, subpath = path.partition(":")122 if not sep:123 raise ValueError("asset path must be absolute file path "124 "or use 'pkg.name:sub/path' format: %r" % (path,))125 return pkg_resources.resource_stream(package, subpath)126 127 128#: type aliases129_sequence_types = (list, tuple)130_set_types = (set, frozenset)131 132#: set of elements that ensure_unique() has validated already.133_ensure_unique_cache = set()134 135 136def _ensure_unique(source, param="source"):137 """138 helper for generators --139 Throws ValueError if source elements aren't unique.140 Error message will display (abbreviated) repr of the duplicates in a string/list141 """142 # check cache to speed things up for frozensets / tuples / strings143 cache = _ensure_unique_cache144 hashable = True145 try:146 if source in cache:147 return True148 except TypeError:149 hashable = False150 151 # check if it has dup elements152 if isinstance(source, _set_types) or len(set(source)) == len(source):153 if hashable:154 try:155 cache.add(source)156 except TypeError:157 # XXX: under pypy, "list() in set()" above doesn't throw TypeError,158 # but trying to add unhashable it to a set *does*.159 pass160 return True161 162 # build list of duplicate values163 seen = set()164 dups = set()165 for elem in source:166 (dups if elem in seen else seen).add(elem)167 dups = sorted(dups)168 trunc = 8169 if len(dups) > trunc:170 trunc = 5171 dup_repr = ", ".join(repr(str(word)) for word in dups[:trunc])172 if len(dups) > trunc:173 dup_repr += ", ... plus %d others" % (len(dups) - trunc)174 175 # throw error176 raise ValueError("`%s` cannot contain duplicate elements: %s" %177 (param, dup_repr))178 179#=============================================================================180# base generator class181#=============================================================================182class SequenceGenerator(object):183 """184 Base class used by word & phrase generators.185 186 These objects take a series of options, corresponding187 to those of the :func:`generate` function.188 They act as callables which can be used to generate a password189 or a list of 1+ passwords. They also expose some read-only190 informational attributes.191 192 Parameters193 ----------194 :param entropy:195 Optionally specify the amount of entropy the resulting passwords196 should contain (as measured with respect to the generator itself).197 This will be used to auto-calculate the required password size.198 199 :param length:200 Optionally specify the length of password to generate,201 measured as count of whatever symbols the subclass uses (characters or words).202 Note if ``entropy`` requires a larger minimum length,203 that will be used instead.204 205 :param rng:206 Optionally provide a custom RNG source to use.207 Should be an instance of :class:`random.Random`,208 defaults to :class:`random.SystemRandom`.209 210 Attributes211 ----------212 .. autoattribute:: length213 .. autoattribute:: symbol_count214 .. autoattribute:: entropy_per_symbol215 .. autoattribute:: entropy216 217 Subclassing218 -----------219 Subclasses must implement the ``.__next__()`` method,220 and set ``.symbol_count`` before calling base ``__init__`` method.221 """222 #=============================================================================223 # instance attrs224 #=============================================================================225 226 #: requested size of final password227 length = None228 229 #: requested entropy of final password230 requested_entropy = "strong"231 232 #: random number source to use233 rng = rng234 235 #: number of potential symbols (must be filled in by subclass)236 symbol_count = None237 238 #=============================================================================239 # init240 #=============================================================================241 def __init__(self, entropy=None, length=None, rng=None, **kwds):242 243 # make sure subclass set things up correctly244 assert self.symbol_count is not None, "subclass must set .symbol_count"245 246 # init length & requested entropy247 if entropy is not None or length is None:248 if entropy is None:249 entropy = self.requested_entropy250 entropy = entropy_aliases.get(entropy, entropy)251 if entropy <= 0:252 raise ValueError("`entropy` must be positive number")253 min_length = int(ceil(entropy / self.entropy_per_symbol))254 if length is None or length < min_length:255 length = min_length256 257 self.requested_entropy = entropy258 259 if length < 1:260 raise ValueError("`length` must be positive integer")261 self.length = length262 263 # init other common options264 if rng is not None:265 self.rng = rng266 267 # hand off to parent268 if kwds and _superclasses(self, SequenceGenerator) == (object,):269 raise TypeError("Unexpected keyword(s): %s" % ", ".join(kwds.keys()))270 super(SequenceGenerator, self).__init__(**kwds)271 272 #=============================================================================273 # informational helpers274 #=============================================================================275 276 @memoized_property277 def entropy_per_symbol(self):278 """279 Average entropy per symbol (assuming all symbols have equal probability)280 """281 return logf(self.symbol_count, 2)282 283 @memoized_property284 def entropy(self):285 """286 Effective entropy of generated passwords.287 288 This value will always be a multiple of :attr:`entropy_per_symbol`.289 If entropy is specified in constructor, :attr:`length` will be chosen so290 so that this value is the smallest multiple >= :attr:`requested_entropy`.291 """292 return self.length * self.entropy_per_symbol293 294 #=============================================================================295 # generation296 #=============================================================================297 def __next__(self):298 """main generation function, should create one password/phrase"""299 raise NotImplementedError("implement in subclass")300 301 def __call__(self, returns=None):302 """303 frontend used by genword() / genphrase() to create passwords304 """305 if returns is None:306 return next(self)307 elif isinstance(returns, int_types):308 return [next(self) for _ in irange(returns)]309 elif returns is iter:310 return self311 else:312 raise exc.ExpectedTypeError(returns, "<None>, int, or <iter>", "returns")313 314 def __iter__(self):315 return self316 317 if PY2:318 def next(self):319 return self.__next__()320 321 #=============================================================================322 # eoc323 #=============================================================================324 325#=============================================================================326# default charsets327#=============================================================================328 329#: global dict of predefined characters sets330default_charsets = dict(331 # ascii letters, digits, and some punctuation332 ascii_72='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*?/',333 334 # ascii letters and digits335 ascii_62='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',336 337 # ascii_50, without visually similar '1IiLl', '0Oo', '5S', '8B'338 ascii_50='234679abcdefghjkmnpqrstuvwxyzACDEFGHJKMNPQRTUVWXYZ',339 340 # lower case hexadecimal341 hex='0123456789abcdef',342)343 344#=============================================================================345# password generator346#=============================================================================347 348class WordGenerator(SequenceGenerator):349 """350 Class which generates passwords by randomly choosing from a string of unique characters.351 352 Parameters353 ----------354 :param chars:355 custom character string to draw from.356 357 :param charset:358 predefined charset to draw from.359 360 :param \\*\\*kwds:361 all other keywords passed to the :class:`SequenceGenerator` parent class.362 363 Attributes364 ----------365 .. autoattribute:: chars366 .. autoattribute:: charset367 .. autoattribute:: default_charsets368 """369 #=============================================================================370 # instance attrs371 #=============================================================================372 373 #: Predefined character set in use (set to None for instances using custom 'chars')374 charset = "ascii_62"375 376 #: string of chars to draw from -- usually filled in from charset377 chars = None378 379 #=============================================================================380 # init381 #=============================================================================382 def __init__(self, chars=None, charset=None, **kwds):383 384 # init chars and charset385 if chars:386 if charset:387 raise TypeError("`chars` and `charset` are mutually exclusive")388 else:389 if not charset:390 charset = self.charset391 assert charset392 chars = default_charsets[charset]393 self.charset = charset394 chars = to_unicode(chars, param="chars")395 _ensure_unique(chars, param="chars")396 self.chars = chars397 398 # hand off to parent399 super(WordGenerator, self).__init__(**kwds)400 # log.debug("WordGenerator(): entropy/char=%r", self.entropy_per_symbol)401 402 #=============================================================================403 # informational helpers404 #=============================================================================405 406 @memoized_property407 def symbol_count(self):408 return len(self.chars)409 410 #=============================================================================411 # generation412 #=============================================================================413 414 def __next__(self):415 # XXX: could do things like optionally ensure certain character groups416 # (e.g. letters & punctuation) are included417 return getrandstr(self.rng, self.chars, self.length)418 419 #=============================================================================420 # eoc421 #=============================================================================422 423 424def genword(entropy=None, length=None, returns=None, **kwds):425 """Generate one or more random passwords.426 427 This function uses :mod:`random.SystemRandom` to generate428 one or more passwords using various character sets.429 The complexity of the password can be specified430 by size, or by the desired amount of entropy.431 432 Usage Example::433 434 >>> # generate a random alphanumeric string with 48 bits of entropy (the default)435 >>> from passlib import pwd436 >>> pwd.genword()437 'DnBHvDjMK6'438 439 >>> # generate a random hexadecimal string with 52 bits of entropy440 >>> pwd.genword(entropy=52, charset="hex")441 '310f1a7ac793f'442 443 :param entropy:444 Strength of resulting password, measured in 'guessing entropy' bits.445 An appropriate **length** value will be calculated446 based on the requested entropy amount, and the size of the character set.447 448 This can be a positive integer, or one of the following preset449 strings: ``"weak"`` (24), ``"fair"`` (36),450 ``"strong"`` (48), and ``"secure"`` (56).451 452 If neither this or **length** is specified, **entropy** will default453 to ``"strong"`` (48).454 455 :param length:456 Size of resulting password, measured in characters.457 If omitted, the size is auto-calculated based on the **entropy** parameter.458 459 If both **entropy** and **length** are specified,460 the stronger value will be used.461 462 :param returns:463 Controls what this function returns:464 465 * If ``None`` (the default), this function will generate a single password.466 * If an integer, this function will return a list containing that many passwords.467 * If the ``iter`` constant, will return an iterator that yields passwords.468 469 :param chars:470 471 Optionally specify custom string of characters to use when randomly472 generating a password. This option cannot be combined with **charset**.473 474 :param charset:475 476 The predefined character set to draw from (if not specified by **chars**).477 There are currently four presets available:478 479 * ``"ascii_62"`` (the default) -- all digits and ascii upper & lowercase letters.480 Provides ~5.95 entropy per character.481 482 * ``"ascii_50"`` -- subset which excludes visually similar characters483 (``1IiLl0Oo5S8B``). Provides ~5.64 entropy per character.484 485 * ``"ascii_72"`` -- all digits and ascii upper & lowercase letters,486 as well as some punctuation. Provides ~6.17 entropy per character.487 488 * ``"hex"`` -- Lower case hexadecimal. Providers 4 bits of entropy per character.489 490 :returns:491 :class:`!unicode` string containing randomly generated password;492 or list of 1+ passwords if :samp:`returns={int}` is specified.493 """494 gen = WordGenerator(length=length, entropy=entropy, **kwds)495 return gen(returns)496 497#=============================================================================498# default wordsets499#=============================================================================500 501def _load_wordset(asset_path):502 """503 load wordset from compressed datafile within package data.504 file should be utf-8 encoded505 506 :param asset_path:507 string containing absolute path to wordset file,508 or "python.module:relative/file/path".509 510 :returns:511 tuple of words, as loaded from specified words file.512 """513 # open resource file, convert to tuple of words (strip blank lines & ws)514 with _open_asset_path(asset_path, "utf-8") as fh:515 gen = (word.strip() for word in fh)516 words = tuple(word for word in gen if word)517 518 # NOTE: works but not used519 # # detect if file uses "<int> <word>" format, and strip numeric prefix520 # def extract(row):521 # idx, word = row.replace("\t", " ").split(" ", 1)522 # if not idx.isdigit():523 # raise ValueError("row is not dice index + word")524 # return word525 # try:526 # extract(words[-1])527 # except ValueError:528 # pass529 # else:530 # words = tuple(extract(word) for word in words)531 532 log.debug("loaded %d-element wordset from %r", len(words), asset_path)533 return words534 535 536class WordsetDict(MutableMapping):537 """538 Special mapping used to store dictionary of wordsets.539 Different from a regular dict in that some wordsets540 may be lazy-loaded from an asset path.541 """542 543 #: dict of key -> asset path544 paths = None545 546 #: dict of key -> value547 _loaded = None548 549 def __init__(self, *args, **kwds):550 self.paths = {}551 self._loaded = {}552 super(WordsetDict, self).__init__(*args, **kwds)553 554 def __getitem__(self, key):555 try:556 return self._loaded[key]557 except KeyError:558 pass559 path = self.paths[key]560 value = self._loaded[key] = _load_wordset(path)561 return value562 563 def set_path(self, key, path):564 """565 set asset path to lazy-load wordset from.566 """567 self.paths[key] = path568 569 def __setitem__(self, key, value):570 self._loaded[key] = value571 572 def __delitem__(self, key):573 if key in self:574 del self._loaded[key]575 self.paths.pop(key, None)576 else:577 del self.paths[key]578 579 @property580 def _keyset(self):581 keys = set(self._loaded)582 keys.update(self.paths)583 return keys584 585 def __iter__(self):586 return iter(self._keyset)587 588 def __len__(self):589 return len(self._keyset)590 591 # NOTE: speeds things up, and prevents contains from lazy-loading592 def __contains__(self, key):593 return key in self._loaded or key in self.paths594 595 596#: dict of predefined word sets.597#: key is name of wordset, value should be sequence of words.598default_wordsets = WordsetDict()599 600# register the wordsets built into passlib601for name in "eff_long eff_short eff_prefixed bip39".split():602 default_wordsets.set_path(name, "passlib:_data/wordsets/%s.txt" % name)603 604#=============================================================================605# passphrase generator606#=============================================================================607class PhraseGenerator(SequenceGenerator):608 """class which generates passphrases by randomly choosing609 from a list of unique words.610 611 :param wordset:612 wordset to draw from.613 :param preset:614 name of preset wordlist to use instead of ``wordset``.615 :param spaces:616 whether to insert spaces between words in output (defaults to ``True``).617 :param \\*\\*kwds:618 all other keywords passed to the :class:`SequenceGenerator` parent class.619 620 .. autoattribute:: wordset621 """622 #=============================================================================623 # instance attrs624 #=============================================================================625 626 #: predefined wordset to use627 wordset = "eff_long"628 629 #: list of words to draw from630 words = None631 632 #: separator to use when joining words633 sep = " "634 635 #=============================================================================636 # init637 #=============================================================================638 def __init__(self, wordset=None, words=None, sep=None, **kwds):639 640 # load wordset641 if words is not None:642 if wordset is not None:643 raise TypeError("`words` and `wordset` are mutually exclusive")644 else:645 if wordset is None:646 wordset = self.wordset647 assert wordset648 words = default_wordsets[wordset]649 self.wordset = wordset650 651 # init words652 if not isinstance(words, _sequence_types):653 words = tuple(words)654 _ensure_unique(words, param="words")655 self.words = words656 657 # init separator658 if sep is None:659 sep = self.sep660 sep = to_unicode(sep, param="sep")661 self.sep = sep662 663 # hand off to parent664 super(PhraseGenerator, self).__init__(**kwds)665 ##log.debug("PhraseGenerator(): entropy/word=%r entropy/char=%r min_chars=%r",666 ## self.entropy_per_symbol, self.entropy_per_char, self.min_chars)667 668 #=============================================================================669 # informational helpers670 #=============================================================================671 672 @memoized_property673 def symbol_count(self):674 return len(self.words)675 676 #=============================================================================677 # generation678 #=============================================================================679 680 def __next__(self):681 words = (self.rng.choice(self.words) for _ in irange(self.length))682 return self.sep.join(words)683 684 #=============================================================================685 # eoc686 #=============================================================================687 688 689def genphrase(entropy=None, length=None, returns=None, **kwds):690 """Generate one or more random password / passphrases.691 692 This function uses :mod:`random.SystemRandom` to generate693 one or more passwords; it can be configured to generate694 alphanumeric passwords, or full english phrases.695 The complexity of the password can be specified696 by size, or by the desired amount of entropy.697 698 Usage Example::699 700 >>> # generate random phrase with 48 bits of entropy701 >>> from passlib import pwd702 >>> pwd.genphrase()703 'gangly robbing salt shove'704 705 >>> # generate a random phrase with 52 bits of entropy706 >>> # using a particular wordset707 >>> pwd.genword(entropy=52, wordset="bip39")708 'wheat dilemma reward rescue diary'709 710 :param entropy:711 Strength of resulting password, measured in 'guessing entropy' bits.712 An appropriate **length** value will be calculated713 based on the requested entropy amount, and the size of the word set.714 715 This can be a positive integer, or one of the following preset716 strings: ``"weak"`` (24), ``"fair"`` (36),717 ``"strong"`` (48), and ``"secure"`` (56).718 719 If neither this or **length** is specified, **entropy** will default720 to ``"strong"`` (48).721 722 :param length:723 Length of resulting password, measured in words.724 If omitted, the size is auto-calculated based on the **entropy** parameter.725 726 If both **entropy** and **length** are specified,727 the stronger value will be used.728 729 :param returns:730 Controls what this function returns:731 732 * If ``None`` (the default), this function will generate a single password.733 * If an integer, this function will return a list containing that many passwords.734 * If the ``iter`` builtin, will return an iterator that yields passwords.735 736 :param words:737 738 Optionally specifies a list/set of words to use when randomly generating a passphrase.739 This option cannot be combined with **wordset**.740 741 :param wordset:742 743 The predefined word set to draw from (if not specified by **words**).744 There are currently four presets available:745 746 ``"eff_long"`` (the default)747 748 Wordset containing 7776 english words of ~7 letters.749 Constructed by the EFF, it offers ~12.9 bits of entropy per word.750 751 This wordset (and the other ``"eff_"`` wordsets)752 were `created by the EFF <https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases>`_753 to aid in generating passwords. See their announcement page754 for more details about the design & properties of these wordsets.755 756 ``"eff_short"``757 758 Wordset containing 1296 english words of ~4.5 letters.759 Constructed by the EFF, it offers ~10.3 bits of entropy per word.760 761 ``"eff_prefixed"``762 763 Wordset containing 1296 english words of ~8 letters,764 selected so that they each have a unique 3-character prefix.765 Constructed by the EFF, it offers ~10.3 bits of entropy per word.766 767 ``"bip39"``768 769 Wordset of 2048 english words of ~5 letters,770 selected so that they each have a unique 4-character prefix.771 Published as part of Bitcoin's `BIP 39 <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>`_,772 this wordset has exactly 11 bits of entropy per word.773 774 This list offers words that are typically shorter than ``"eff_long"``775 (at the cost of slightly less entropy); and much shorter than776 ``"eff_prefixed"`` (at the cost of a longer unique prefix).777 778 :param sep:779 Optional separator to use when joining words.780 Defaults to ``" "`` (a space), but can be an empty string, a hyphen, etc.781 782 :returns:783 :class:`!unicode` string containing randomly generated passphrase;784 or list of 1+ passphrases if :samp:`returns={int}` is specified.785 """786 gen = PhraseGenerator(entropy=entropy, length=length, **kwds)787 return gen(returns)788 789#=============================================================================790# strength measurement791#792# NOTE:793# for a little while, had rough draft of password strength measurement alg here.794# but not sure if there's value in yet another measurement algorithm,795# that's not just duplicating the effort of libraries like zxcbn.796# may revive it later, but for now, leaving some refs to others out there:797# * NIST 800-63 has simple alg798# * zxcvbn (https://tech.dropbox.com/2012/04/zxcvbn-realistic-password-strength-estimation/)799# might also be good, and has approach similar to composite approach i was already thinking about,800# but much more well thought out.801# * passfault (https://github.com/c-a-m/passfault) looks thorough,802# but may have licensing issues, plus porting to python looks like very big job :(803# * give a look at running things through zlib - might be able to cheaply804# catch extra redundancies.805#=============================================================================806 807#=============================================================================808# eof809#=============================================================================810 