You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

805 rivejä
28 KiB

  1. """passlib.pwd -- password generation helpers"""
  2. #=============================================================================
  3. # imports
  4. #=============================================================================
  5. from __future__ import absolute_import, division, print_function, unicode_literals
  6. # core
  7. import codecs
  8. from collections import defaultdict, MutableMapping
  9. from math import ceil, log as logf
  10. import logging; log = logging.getLogger(__name__)
  11. import pkg_resources
  12. import os
  13. # site
  14. # pkg
  15. from passlib import exc
  16. from passlib.utils.compat import PY2, irange, itervalues, int_types
  17. from passlib.utils import rng, getrandstr, to_unicode
  18. from passlib.utils.decor import memoized_property
  19. # local
  20. __all__ = [
  21. "genword", "default_charsets",
  22. "genphrase", "default_wordsets",
  23. ]
  24. #=============================================================================
  25. # constants
  26. #=============================================================================
  27. # XXX: rename / publically document this map?
  28. entropy_aliases = dict(
  29. # barest protection from throttled online attack
  30. unsafe=12,
  31. # some protection from unthrottled online attack
  32. weak=24,
  33. # some protection from offline attacks
  34. fair=36,
  35. # reasonable protection from offline attacks
  36. strong=48,
  37. # very good protection from offline attacks
  38. secure=60,
  39. )
  40. #=============================================================================
  41. # internal helpers
  42. #=============================================================================
  43. def _superclasses(obj, cls):
  44. """return remaining classes in object's MRO after cls"""
  45. mro = type(obj).__mro__
  46. return mro[mro.index(cls)+1:]
  47. def _self_info_rate(source):
  48. """
  49. returns 'rate of self-information' --
  50. i.e. average (per-symbol) entropy of the sequence **source**,
  51. where probability of a given symbol occurring is calculated based on
  52. the number of occurrences within the sequence itself.
  53. if all elements of the source are unique, this should equal ``log(len(source), 2)``.
  54. :arg source:
  55. iterable containing 0+ symbols
  56. (e.g. list of strings or ints, string of characters, etc).
  57. :returns:
  58. float bits of entropy
  59. """
  60. try:
  61. size = len(source)
  62. except TypeError:
  63. # if len() doesn't work, calculate size by summing counts later
  64. size = None
  65. counts = defaultdict(int)
  66. for char in source:
  67. counts[char] += 1
  68. if size is None:
  69. values = counts.values()
  70. size = sum(values)
  71. else:
  72. values = itervalues(counts)
  73. if not size:
  74. return 0
  75. # NOTE: the following performs ``- sum(value / size * logf(value / size, 2) for value in values)``,
  76. # it just does so with as much pulled out of the sum() loop as possible...
  77. return logf(size, 2) - sum(value * logf(value, 2) for value in values) / size
  78. # def _total_self_info(source):
  79. # """
  80. # return total self-entropy of a sequence
  81. # (the average entropy per symbol * size of sequence)
  82. # """
  83. # return _self_info_rate(source) * len(source)
  84. def _open_asset_path(path, encoding=None):
  85. """
  86. :param asset_path:
  87. string containing absolute path to file,
  88. or package-relative path using format
  89. ``"python.module:relative/file/path"``.
  90. :returns:
  91. filehandle opened in 'rb' mode
  92. (unless encoding explicitly specified)
  93. """
  94. if encoding:
  95. return codecs.getreader(encoding)(_open_asset_path(path))
  96. if os.path.isabs(path):
  97. return open(path, "rb")
  98. package, sep, subpath = path.partition(":")
  99. if not sep:
  100. raise ValueError("asset path must be absolute file path "
  101. "or use 'pkg.name:sub/path' format: %r" % (path,))
  102. return pkg_resources.resource_stream(package, subpath)
  103. #: type aliases
  104. _sequence_types = (list, tuple)
  105. _set_types = (set, frozenset)
  106. #: set of elements that ensure_unique() has validated already.
  107. _ensure_unique_cache = set()
  108. def _ensure_unique(source, param="source"):
  109. """
  110. helper for generators --
  111. Throws ValueError if source elements aren't unique.
  112. Error message will display (abbreviated) repr of the duplicates in a string/list
  113. """
  114. # check cache to speed things up for frozensets / tuples / strings
  115. cache = _ensure_unique_cache
  116. hashable = True
  117. try:
  118. if source in cache:
  119. return True
  120. except TypeError:
  121. hashable = False
  122. # check if it has dup elements
  123. if isinstance(source, _set_types) or len(set(source)) == len(source):
  124. if hashable:
  125. try:
  126. cache.add(source)
  127. except TypeError:
  128. # XXX: under pypy, "list() in set()" above doesn't throw TypeError,
  129. # but trying to add unhashable it to a set *does*.
  130. pass
  131. return True
  132. # build list of duplicate values
  133. seen = set()
  134. dups = set()
  135. for elem in source:
  136. (dups if elem in seen else seen).add(elem)
  137. dups = sorted(dups)
  138. trunc = 8
  139. if len(dups) > trunc:
  140. trunc = 5
  141. dup_repr = ", ".join(repr(str(word)) for word in dups[:trunc])
  142. if len(dups) > trunc:
  143. dup_repr += ", ... plus %d others" % (len(dups) - trunc)
  144. # throw error
  145. raise ValueError("`%s` cannot contain duplicate elements: %s" %
  146. (param, dup_repr))
  147. #=============================================================================
  148. # base generator class
  149. #=============================================================================
  150. class SequenceGenerator(object):
  151. """
  152. Base class used by word & phrase generators.
  153. These objects take a series of options, corresponding
  154. to those of the :func:`generate` function.
  155. They act as callables which can be used to generate a password
  156. or a list of 1+ passwords. They also expose some read-only
  157. informational attributes.
  158. Parameters
  159. ----------
  160. :param entropy:
  161. Optionally specify the amount of entropy the resulting passwords
  162. should contain (as measured with respect to the generator itself).
  163. This will be used to auto-calculate the required password size.
  164. :param length:
  165. Optionally specify the length of password to generate,
  166. measured as count of whatever symbols the subclass uses (characters or words).
  167. Note if ``entropy`` requires a larger minimum length,
  168. that will be used instead.
  169. :param rng:
  170. Optionally provide a custom RNG source to use.
  171. Should be an instance of :class:`random.Random`,
  172. defaults to :class:`random.SystemRandom`.
  173. Attributes
  174. ----------
  175. .. autoattribute:: length
  176. .. autoattribute:: symbol_count
  177. .. autoattribute:: entropy_per_symbol
  178. .. autoattribute:: entropy
  179. Subclassing
  180. -----------
  181. Subclasses must implement the ``.__next__()`` method,
  182. and set ``.symbol_count`` before calling base ``__init__`` method.
  183. """
  184. #=============================================================================
  185. # instance attrs
  186. #=============================================================================
  187. #: requested size of final password
  188. length = None
  189. #: requested entropy of final password
  190. requested_entropy = "strong"
  191. #: random number source to use
  192. rng = rng
  193. #: number of potential symbols (must be filled in by subclass)
  194. symbol_count = None
  195. #=============================================================================
  196. # init
  197. #=============================================================================
  198. def __init__(self, entropy=None, length=None, rng=None, **kwds):
  199. # make sure subclass set things up correctly
  200. assert self.symbol_count is not None, "subclass must set .symbol_count"
  201. # init length & requested entropy
  202. if entropy is not None or length is None:
  203. if entropy is None:
  204. entropy = self.requested_entropy
  205. entropy = entropy_aliases.get(entropy, entropy)
  206. if entropy <= 0:
  207. raise ValueError("`entropy` must be positive number")
  208. min_length = int(ceil(entropy / self.entropy_per_symbol))
  209. if length is None or length < min_length:
  210. length = min_length
  211. self.requested_entropy = entropy
  212. if length < 1:
  213. raise ValueError("`length` must be positive integer")
  214. self.length = length
  215. # init other common options
  216. if rng is not None:
  217. self.rng = rng
  218. # hand off to parent
  219. if kwds and _superclasses(self, SequenceGenerator) == (object,):
  220. raise TypeError("Unexpected keyword(s): %s" % ", ".join(kwds.keys()))
  221. super(SequenceGenerator, self).__init__(**kwds)
  222. #=============================================================================
  223. # informational helpers
  224. #=============================================================================
  225. @memoized_property
  226. def entropy_per_symbol(self):
  227. """
  228. Average entropy per symbol (assuming all symbols have equal probability)
  229. """
  230. return logf(self.symbol_count, 2)
  231. @memoized_property
  232. def entropy(self):
  233. """
  234. Effective entropy of generated passwords.
  235. This value will always be a multiple of :attr:`entropy_per_symbol`.
  236. If entropy is specified in constructor, :attr:`length` will be chosen so
  237. so that this value is the smallest multiple >= :attr:`requested_entropy`.
  238. """
  239. return self.length * self.entropy_per_symbol
  240. #=============================================================================
  241. # generation
  242. #=============================================================================
  243. def __next__(self):
  244. """main generation function, should create one password/phrase"""
  245. raise NotImplementedError("implement in subclass")
  246. def __call__(self, returns=None):
  247. """
  248. frontend used by genword() / genphrase() to create passwords
  249. """
  250. if returns is None:
  251. return next(self)
  252. elif isinstance(returns, int_types):
  253. return [next(self) for _ in irange(returns)]
  254. elif returns is iter:
  255. return self
  256. else:
  257. raise exc.ExpectedTypeError(returns, "<None>, int, or <iter>", "returns")
  258. def __iter__(self):
  259. return self
  260. if PY2:
  261. def next(self):
  262. return self.__next__()
  263. #=============================================================================
  264. # eoc
  265. #=============================================================================
  266. #=============================================================================
  267. # default charsets
  268. #=============================================================================
  269. #: global dict of predefined characters sets
  270. default_charsets = dict(
  271. # ascii letters, digits, and some punctuation
  272. ascii_72='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*?/',
  273. # ascii letters and digits
  274. ascii_62='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
  275. # ascii_50, without visually similar '1IiLl', '0Oo', '5S', '8B'
  276. ascii_50='234679abcdefghjkmnpqrstuvwxyzACDEFGHJKMNPQRTUVWXYZ',
  277. # lower case hexadecimal
  278. hex='0123456789abcdef',
  279. )
  280. #=============================================================================
  281. # password generator
  282. #=============================================================================
  283. class WordGenerator(SequenceGenerator):
  284. """
  285. Class which generates passwords by randomly choosing from a string of unique characters.
  286. Parameters
  287. ----------
  288. :param chars:
  289. custom character string to draw from.
  290. :param charset:
  291. predefined charset to draw from.
  292. :param \*\*kwds:
  293. all other keywords passed to the :class:`SequenceGenerator` parent class.
  294. Attributes
  295. ----------
  296. .. autoattribute:: chars
  297. .. autoattribute:: charset
  298. .. autoattribute:: default_charsets
  299. """
  300. #=============================================================================
  301. # instance attrs
  302. #=============================================================================
  303. #: Predefined character set in use (set to None for instances using custom 'chars')
  304. charset = "ascii_62"
  305. #: string of chars to draw from -- usually filled in from charset
  306. chars = None
  307. #=============================================================================
  308. # init
  309. #=============================================================================
  310. def __init__(self, chars=None, charset=None, **kwds):
  311. # init chars and charset
  312. if chars:
  313. if charset:
  314. raise TypeError("`chars` and `charset` are mutually exclusive")
  315. else:
  316. if not charset:
  317. charset = self.charset
  318. assert charset
  319. chars = default_charsets[charset]
  320. self.charset = charset
  321. chars = to_unicode(chars, param="chars")
  322. _ensure_unique(chars, param="chars")
  323. self.chars = chars
  324. # hand off to parent
  325. super(WordGenerator, self).__init__(**kwds)
  326. # log.debug("WordGenerator(): entropy/char=%r", self.entropy_per_symbol)
  327. #=============================================================================
  328. # informational helpers
  329. #=============================================================================
  330. @memoized_property
  331. def symbol_count(self):
  332. return len(self.chars)
  333. #=============================================================================
  334. # generation
  335. #=============================================================================
  336. def __next__(self):
  337. # XXX: could do things like optionally ensure certain character groups
  338. # (e.g. letters & punctuation) are included
  339. return getrandstr(self.rng, self.chars, self.length)
  340. #=============================================================================
  341. # eoc
  342. #=============================================================================
  343. def genword(entropy=None, length=None, returns=None, **kwds):
  344. """Generate one or more random passwords.
  345. This function uses :mod:`random.SystemRandom` to generate
  346. one or more passwords using various character sets.
  347. The complexity of the password can be specified
  348. by size, or by the desired amount of entropy.
  349. Usage Example::
  350. >>> # generate a random alphanumeric string with 48 bits of entropy (the default)
  351. >>> from passlib import pwd
  352. >>> pwd.genword()
  353. 'DnBHvDjMK6'
  354. >>> # generate a random hexadecimal string with 52 bits of entropy
  355. >>> pwd.genword(entropy=52, charset="hex")
  356. '310f1a7ac793f'
  357. :param entropy:
  358. Strength of resulting password, measured in 'guessing entropy' bits.
  359. An appropriate **length** value will be calculated
  360. based on the requested entropy amount, and the size of the character set.
  361. This can be a positive integer, or one of the following preset
  362. strings: ``"weak"`` (24), ``"fair"`` (36),
  363. ``"strong"`` (48), and ``"secure"`` (56).
  364. If neither this or **length** is specified, **entropy** will default
  365. to ``"strong"`` (48).
  366. :param length:
  367. Size of resulting password, measured in characters.
  368. If omitted, the size is auto-calculated based on the **entropy** parameter.
  369. If both **entropy** and **length** are specified,
  370. the stronger value will be used.
  371. :param returns:
  372. Controls what this function returns:
  373. * If ``None`` (the default), this function will generate a single password.
  374. * If an integer, this function will return a list containing that many passwords.
  375. * If the ``iter`` constant, will return an iterator that yields passwords.
  376. :param chars:
  377. Optionally specify custom string of characters to use when randomly
  378. generating a password. This option cannot be combined with **charset**.
  379. :param charset:
  380. The predefined character set to draw from (if not specified by **chars**).
  381. There are currently four presets available:
  382. * ``"ascii_62"`` (the default) -- all digits and ascii upper & lowercase letters.
  383. Provides ~5.95 entropy per character.
  384. * ``"ascii_50"`` -- subset which excludes visually similar characters
  385. (``1IiLl0Oo5S8B``). Provides ~5.64 entropy per character.
  386. * ``"ascii_72"`` -- all digits and ascii upper & lowercase letters,
  387. as well as some punctuation. Provides ~6.17 entropy per character.
  388. * ``"hex"`` -- Lower case hexadecimal. Providers 4 bits of entropy per character.
  389. :returns:
  390. :class:`!unicode` string containing randomly generated password;
  391. or list of 1+ passwords if :samp:`returns={int}` is specified.
  392. """
  393. gen = WordGenerator(length=length, entropy=entropy, **kwds)
  394. return gen(returns)
  395. #=============================================================================
  396. # default wordsets
  397. #=============================================================================
  398. def _load_wordset(asset_path):
  399. """
  400. load wordset from compressed datafile within package data.
  401. file should be utf-8 encoded
  402. :param asset_path:
  403. string containing absolute path to wordset file,
  404. or "python.module:relative/file/path".
  405. :returns:
  406. tuple of words, as loaded from specified words file.
  407. """
  408. # open resource file, convert to tuple of words (strip blank lines & ws)
  409. with _open_asset_path(asset_path, "utf-8") as fh:
  410. gen = (word.strip() for word in fh)
  411. words = tuple(word for word in gen if word)
  412. # NOTE: works but not used
  413. # # detect if file uses "<int> <word>" format, and strip numeric prefix
  414. # def extract(row):
  415. # idx, word = row.replace("\t", " ").split(" ", 1)
  416. # if not idx.isdigit():
  417. # raise ValueError("row is not dice index + word")
  418. # return word
  419. # try:
  420. # extract(words[-1])
  421. # except ValueError:
  422. # pass
  423. # else:
  424. # words = tuple(extract(word) for word in words)
  425. log.debug("loaded %d-element wordset from %r", len(words), asset_path)
  426. return words
  427. class WordsetDict(MutableMapping):
  428. """
  429. Special mapping used to store dictionary of wordsets.
  430. Different from a regular dict in that some wordsets
  431. may be lazy-loaded from an asset path.
  432. """
  433. #: dict of key -> asset path
  434. paths = None
  435. #: dict of key -> value
  436. _loaded = None
  437. def __init__(self, *args, **kwds):
  438. self.paths = {}
  439. self._loaded = {}
  440. super(WordsetDict, self).__init__(*args, **kwds)
  441. def __getitem__(self, key):
  442. try:
  443. return self._loaded[key]
  444. except KeyError:
  445. pass
  446. path = self.paths[key]
  447. value = self._loaded[key] = _load_wordset(path)
  448. return value
  449. def set_path(self, key, path):
  450. """
  451. set asset path to lazy-load wordset from.
  452. """
  453. self.paths[key] = path
  454. def __setitem__(self, key, value):
  455. self._loaded[key] = value
  456. def __delitem__(self, key):
  457. if key in self:
  458. del self._loaded[key]
  459. self.paths.pop(key, None)
  460. else:
  461. del self.paths[key]
  462. @property
  463. def _keyset(self):
  464. keys = set(self._loaded)
  465. keys.update(self.paths)
  466. return keys
  467. def __iter__(self):
  468. return iter(self._keyset)
  469. def __len__(self):
  470. return len(self._keyset)
  471. # NOTE: speeds things up, and prevents contains from lazy-loading
  472. def __contains__(self, key):
  473. return key in self._loaded or key in self.paths
  474. #: dict of predefined word sets.
  475. #: key is name of wordset, value should be sequence of words.
  476. default_wordsets = WordsetDict()
  477. # register the wordsets built into passlib
  478. for name in "eff_long eff_short eff_prefixed bip39".split():
  479. default_wordsets.set_path(name, "passlib:_data/wordsets/%s.txt" % name)
  480. #=============================================================================
  481. # passphrase generator
  482. #=============================================================================
  483. class PhraseGenerator(SequenceGenerator):
  484. """class which generates passphrases by randomly choosing
  485. from a list of unique words.
  486. :param wordset:
  487. wordset to draw from.
  488. :param preset:
  489. name of preset wordlist to use instead of ``wordset``.
  490. :param spaces:
  491. whether to insert spaces between words in output (defaults to ``True``).
  492. :param \*\*kwds:
  493. all other keywords passed to the :class:`SequenceGenerator` parent class.
  494. .. autoattribute:: wordset
  495. """
  496. #=============================================================================
  497. # instance attrs
  498. #=============================================================================
  499. #: predefined wordset to use
  500. wordset = "eff_long"
  501. #: list of words to draw from
  502. words = None
  503. #: separator to use when joining words
  504. sep = " "
  505. #=============================================================================
  506. # init
  507. #=============================================================================
  508. def __init__(self, wordset=None, words=None, sep=None, **kwds):
  509. # load wordset
  510. if words is not None:
  511. if wordset is not None:
  512. raise TypeError("`words` and `wordset` are mutually exclusive")
  513. else:
  514. if wordset is None:
  515. wordset = self.wordset
  516. assert wordset
  517. words = default_wordsets[wordset]
  518. self.wordset = wordset
  519. # init words
  520. if not isinstance(words, _sequence_types):
  521. words = tuple(words)
  522. _ensure_unique(words, param="words")
  523. self.words = words
  524. # init separator
  525. if sep is None:
  526. sep = self.sep
  527. sep = to_unicode(sep, param="sep")
  528. self.sep = sep
  529. # hand off to parent
  530. super(PhraseGenerator, self).__init__(**kwds)
  531. ##log.debug("PhraseGenerator(): entropy/word=%r entropy/char=%r min_chars=%r",
  532. ## self.entropy_per_symbol, self.entropy_per_char, self.min_chars)
  533. #=============================================================================
  534. # informational helpers
  535. #=============================================================================
  536. @memoized_property
  537. def symbol_count(self):
  538. return len(self.words)
  539. #=============================================================================
  540. # generation
  541. #=============================================================================
  542. def __next__(self):
  543. words = (self.rng.choice(self.words) for _ in irange(self.length))
  544. return self.sep.join(words)
  545. #=============================================================================
  546. # eoc
  547. #=============================================================================
  548. def genphrase(entropy=None, length=None, returns=None, **kwds):
  549. """Generate one or more random password / passphrases.
  550. This function uses :mod:`random.SystemRandom` to generate
  551. one or more passwords; it can be configured to generate
  552. alphanumeric passwords, or full english phrases.
  553. The complexity of the password can be specified
  554. by size, or by the desired amount of entropy.
  555. Usage Example::
  556. >>> # generate random phrase with 48 bits of entropy
  557. >>> from passlib import pwd
  558. >>> pwd.genphrase()
  559. 'gangly robbing salt shove'
  560. >>> # generate a random phrase with 52 bits of entropy
  561. >>> # using a particular wordset
  562. >>> pwd.genword(entropy=52, wordset="bip39")
  563. 'wheat dilemma reward rescue diary'
  564. :param entropy:
  565. Strength of resulting password, measured in 'guessing entropy' bits.
  566. An appropriate **length** value will be calculated
  567. based on the requested entropy amount, and the size of the word set.
  568. This can be a positive integer, or one of the following preset
  569. strings: ``"weak"`` (24), ``"fair"`` (36),
  570. ``"strong"`` (48), and ``"secure"`` (56).
  571. If neither this or **length** is specified, **entropy** will default
  572. to ``"strong"`` (48).
  573. :param length:
  574. Length of resulting password, measured in words.
  575. If omitted, the size is auto-calculated based on the **entropy** parameter.
  576. If both **entropy** and **length** are specified,
  577. the stronger value will be used.
  578. :param returns:
  579. Controls what this function returns:
  580. * If ``None`` (the default), this function will generate a single password.
  581. * If an integer, this function will return a list containing that many passwords.
  582. * If the ``iter`` builtin, will return an iterator that yields passwords.
  583. :param words:
  584. Optionally specifies a list/set of words to use when randomly generating a passphrase.
  585. This option cannot be combined with **wordset**.
  586. :param wordset:
  587. The predefined word set to draw from (if not specified by **words**).
  588. There are currently four presets available:
  589. ``"eff_long"`` (the default)
  590. Wordset containing 7776 english words of ~7 letters.
  591. Constructed by the EFF, it offers ~12.9 bits of entropy per word.
  592. This wordset (and the other ``"eff_"`` wordsets)
  593. were `created by the EFF <https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases>`_
  594. to aid in generating passwords. See their announcement page
  595. for more details about the design & properties of these wordsets.
  596. ``"eff_short"``
  597. Wordset containing 1296 english words of ~4.5 letters.
  598. Constructed by the EFF, it offers ~10.3 bits of entropy per word.
  599. ``"eff_prefixed"``
  600. Wordset containing 1296 english words of ~8 letters,
  601. selected so that they each have a unique 3-character prefix.
  602. Constructed by the EFF, it offers ~10.3 bits of entropy per word.
  603. ``"bip39"``
  604. Wordset of 2048 english words of ~5 letters,
  605. selected so that they each have a unique 4-character prefix.
  606. Published as part of Bitcoin's `BIP 39 <https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt>`_,
  607. this wordset has exactly 11 bits of entropy per word.
  608. This list offers words that are typically shorter than ``"eff_long"``
  609. (at the cost of slightly less entropy); and much shorter than
  610. ``"eff_prefixed"`` (at the cost of a longer unique prefix).
  611. :param sep:
  612. Optional separator to use when joining words.
  613. Defaults to ``" "`` (a space), but can be an empty string, a hyphen, etc.
  614. :returns:
  615. :class:`!unicode` string containing randomly generated passphrase;
  616. or list of 1+ passphrases if :samp:`returns={int}` is specified.
  617. """
  618. gen = PhraseGenerator(entropy=entropy, length=length, **kwds)
  619. return gen(returns)
  620. #=============================================================================
  621. # strength measurement
  622. #
  623. # NOTE:
  624. # for a little while, had rough draft of password strength measurement alg here.
  625. # but not sure if there's value in yet another measurement algorithm,
  626. # that's not just duplicating the effort of libraries like zxcbn.
  627. # may revive it later, but for now, leaving some refs to others out there:
  628. # * NIST 800-63 has simple alg
  629. # * zxcvbn (https://tech.dropbox.com/2012/04/zxcvbn-realistic-password-strength-estimation/)
  630. # might also be good, and has approach similar to composite approach i was already thinking about,
  631. # but much more well thought out.
  632. # * passfault (https://github.com/c-a-m/passfault) looks thorough,
  633. # but may have licensing issues, plus porting to python looks like very big job :(
  634. # * give a look at running things through zlib - might be able to cheaply
  635. # catch extra redundancies.
  636. #=============================================================================
  637. #=============================================================================
  638. # eof
  639. #=============================================================================