Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 

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