5
\$\begingroup\$

I'm aware that people have implemented password/passphrase generators before, but I still went ahead and wrote my own, which I actually use for my own passwords and/or phrases.

By "secure", I mean that the strength of the results is maintained even if an attacker knows all of the following:

  • the list of words used,
  • the set of alphanumeric characters used,
  • that this program was used to generate the result,
  • what the source code of this program looks like, and/or
  • the amount of entropy produced by the process

In other words (paraphrasing Shannon), the attacker knows the whole system. The program generates both passphrases and passwords.

The passphrase is generated from a words list included as part of the installation of this program. The words list was created by the Electronic Frontier Foundation (EFF), and can be found here. There are 7776 words in the list, meant for diceware. (The only processing I did to the file was to remove the left column with the numbers and leave the words only.)

Passwords are generated from ASCII lower/upper-case characters, digits, and punctuation symbols, for a total 94 symbols.

The code is divided into several modules:

  • makesecret: the main program;
  • validators: allows makesecret to validate some inputs with argparse;
  • providers: classes that generate the actual secrets, e.g. PasswordProvider, PassphraseProvider; (I avoided using Generator to prevent ambiguity when talking about actual python generators)
  • analyzers: provides a simple analysis of the generated result to the user in a way they can understand (e.g. average time required to guess it, etc)

There's also a tests/ package with some automated test cases, but I've chosen not to include those here. (If you'd like to see them, just post a comment to that effect.)

The source code is below. While anything is fair game, I'm primarily interested in the following:

  • any issues with my stated assumptions regarding security;
  • any issues with the implementation that weakens the results generated by the program;
  • any issues that might cause problems in other non-GNU+Linux platforms (e.g. it doesn't work in Windows/macOS unless _____, etc)
  • other design/implementation improvements or details I may've missed

makesecret.py

This is the main program. It parses CLI arguments, validates them using a validator, and relies on providers to generate the secret.

#!/usr/bin/env python3

'''Generate cryptographically strong passphrases and passwords'''

import sys

import argparse as ap
from pkg_resources import resource_filename

from makesecret.providers import PasswordProvider, PassphraseProvider
from makesecret.analyzers import analyze_secret
from makesecret.validators import valid_positive_number


_epilog = 'IMPORTANT: Read the documentation to properly understand '   \
          ' how to use this program. In GNU+Linux, you can use '        \
          '`pydoc3 makesecret`. In Windows you may use `py -m pydoc '   \
          'makesecret`.'


_providers = {
    'password'  : PasswordProvider,
    'passphrase': PassphraseProvider
}


def parse_arguments() -> ap.Namespace:
    '''Parse CLI arguments with an `ArgumentParser`.'''
    parser = ap.ArgumentParser(
        description='A secure password and passphrase generator',
        formatter_class=ap.ArgumentDefaultsHelpFormatter,
        epilog=_epilog
    )
    parser.add_argument(
        '--brute-force',
        metavar='ATTEMPTS',
        type=valid_positive_number,
        dest='attacks_per_sec',
        default=10**9,
        help='assumed guess attempts per second'
    )
    parser.add_argument(
        '--min-entropy',
        metavar='BITS',
        type=valid_positive_number,
        default=60,
        dest='min_entropy_bits',
        help='minimum amount of entropy bits to consider result acceptable'
    )
    parser.add_argument(
        '--precision',
        metavar='PLACES',
        dest='precision',
        type=valid_positive_number,
        default=4,
        help='the number of decimal places to show during analysis'
    )
    subparser = parser.add_subparsers(
        dest='command',
        title='subcommands',
        help='additional help for specific subcommands'
    )
    subparser.required = True
    setup_passphrase_parser(subparser)
    setup_password_parser(subparser)
    return parser.parse_args()


def setup_passphrase_parser(subparser: ap.ArgumentParser) -> None:
    '''Setup passphrase-specific parsing options.'''
    parser = subparser.add_parser(
        'passphrase',
        formatter_class=ap.ArgumentDefaultsHelpFormatter,
        epilog=_epilog,
        help='help for generating passphrases'
    )
    parser.add_argument(
        '--choose',
        metavar='COUNT',
        type=valid_positive_number,
        dest='user_choice_cnt',
        default=6,
        help='the number of words to choose from the words list'
    )
    parser.add_argument(
        '--words-list',
        metavar='FILE',
        type=str,
        dest='wordlist_file',
        default=resource_filename('makesecret', 'data/eff_wordlist.db'),
        help='a text file with a long list of words (one per line)'
    )
    parser.add_argument(
        '--analyze',
        action='store_true',
        dest='show_analysis',
        help='show a simple analysis of your new password/passphrase'
    )


def setup_password_parser(subparser: ap.ArgumentParser) -> None:
    '''Setup password-specific parsing options.'''
    parser = subparser.add_parser(
        'password',
        formatter_class=ap.ArgumentDefaultsHelpFormatter,
        epilog=_epilog,
        help='help for generating passwords'
    )
    parser.add_argument(
        '--choose',
        metavar='COUNT',
        type=valid_positive_number,
        dest='user_choice_cnt',
        default=12,
        help='the number of characters to choose from the alphanumeric set'
    )
    parser.add_argument(
        '--analyze',
        action='store_true',
        dest='show_analysis',
        help='show a simple analysis of your new password/passphrase'
    )


def main():
    args = parse_arguments()

    provider = _providers[args.command](args)

    secret = provider.generate()
    print(str(secret))

    if args.show_analysis:
        msg = analyze_secret(
            secret=secret,
            user_choice_cnt=args.user_choice_cnt,
            attacks_sec=args.attacks_per_sec,
            min_entropy=args.min_entropy_bits,
            precision=args.precision
        )
        print(msg, file=sys.stderr)

    sys.exit(0)


if __name__ == '__main__':
    main()

validators.py

Validates that CLI arguments that should be positive numbers (e.g. minimum entropy bits, number of words/chars to choose, etc) are actually positive.

import argparse as ap


def valid_positive_number(s: str) -> int:
    '''Validates that input is a positive integer.'''
    msg = 'value must be a positive integer'
    try:
        n = int(s)
        if n <= 0:
            raise ap.ArgumentTypeError(msg)
        return n
    except ValueError:
        raise ap.ArgumentTypeError(msg)

providers.py

The providers are the classes that actually generate the secrets. A Secret class is used to represent the result.

import argparse as ap

from secrets import choice
from string import ascii_letters, digits, punctuation
from abc import ABCMeta, abstractmethod
from typing import Text


class Secret:
    '''A class to store the result returned by a provider.'''

    def __init__(self, result: Text, pool_size: int):
        self._result = result
        self._choice_pool_size = pool_size

    @property
    def result(self) -> Text:
        return self._result

    @property
    def choice_pool_size(self) -> int:
        return self._choice_pool_size

    def __str__(self):
        return self.result


class _SecretProvider(metaclass=ABCMeta):
    '''ABC for providers.'''
    @abstractmethod
    def generate(self) -> Secret:
        raise NotImplementedError()


class PasswordProvider(_SecretProvider):
    '''A generator for passwords based on ascii symbols.'''

    def __init__(self, args: ap.Namespace):
        self._args = args

    def generate(self) -> Secret:
        '''Generate a password using a set of alphanumeric symbols.

        The alphanumeric set includes lower/upper-case letters, numbers,
        and punctuation symbols.
        '''
        options = ascii_letters + digits + punctuation
        return Secret(
            ''.join(choice(options) for i in range(self._args.user_choice_cnt)),
            len(options)
        )


class PassphraseProvider(_SecretProvider):
    '''A generator for word-based passphrases from word lists.'''

    def __init__(self, args: ap.Namespace):
        self._args = args

    def generate(self) -> Secret:
        '''Generate a passphrase from a list of common words.

        The words list comes from the Electronic Frontier Foundation
        (EFF) and it's made up of common words, so it should be fairly
        easy to remember.
        '''
        with open(self._args.wordlist_file, 'r') as words:
            options = [word.strip() for word in words]
            return Secret(
                ' '.join(choice(options) for i in range(self._args.user_choice_cnt)),
                len(options)
            )

analyzers.py

Provides an analysis of a given result in a way that's more user-friendly and easy to understand. For example, stating the amount of entropy is unlikely to be meaningful to the user, so I also provide information in terms of "time to brute-force" at an assumed attack rate.

import argparse as ap

from typing import Text
from os import linesep as eol
from math import log2

from makesecret.providers import Secret


def analyze_secret(**kw) -> Text:
    '''Analyze results.'''
    secret      = kw['secret']
    choice_cnt  = kw['user_choice_cnt']
    attacks_sec = kw['attacks_sec']
    min_entropy = kw['min_entropy']
    precision   = kw['precision']

    entropy_base   = secret.choice_pool_size
    entropy_bits   = log2(entropy_base)
    entropy_total  = entropy_bits * choice_cnt
    tries_to_crack = int(2**(entropy_total-1))  # average

    # average time to crack the user's result, assuming
    # `attacks_sec` attempts per second
    crack_secs      = tries_to_crack / attacks_sec
    crack_hours     = crack_secs  / 3600
    crack_days      = crack_hours / 24
    crack_weeks     = crack_days  / 7
    crack_years     = crack_days  / 365.25
    crack_decades   = crack_years / 10
    crack_centuries = crack_years / 10**2
    crack_millenia  = crack_years / 10**3
    crack_millions  = crack_years / 10**6
    crack_billions  = crack_years / 10**9

    prec = precision

    msg = eol
    msg += 'Facts About Your Result' + eol
    msg += '  Selection Pool            : {:,}'.format(entropy_base) + eol
    msg += '  Entropy (bits per choice) : {:,.{p}f}'.format(entropy_bits, p=prec) + eol
    msg += '  Entropy (bits overall)    : {:,.{p}f}'.format(entropy_total, p=prec) + eol + eol
    msg += 'Attack: Brute-Force ({:,} attemps/sec assumed)'.format(attacks_sec) + eol
    msg += '  Guess Attempts (avg.): {:,}'.format(tries_to_crack) + eol
    msg += '  Time to Crack (avg.) :' + eol
    msg += '    Seconds   : {:,.{p}f}'.format(crack_secs,      p=prec) + eol
    msg += '    Hours     : {:,.{p}f}'.format(crack_hours,     p=prec) + eol
    msg += '    Days      : {:,.{p}f}'.format(crack_days,      p=prec) + eol
    msg += '    Weeks     : {:,.{p}f}'.format(crack_weeks,     p=prec) + eol
    msg += '    Years     : {:,.{p}f}'.format(crack_years,     p=prec) + eol
    msg += '    Decades   : {:,.{p}f}'.format(crack_decades,   p=prec) + eol
    msg += '    Centuries : {:,.{p}f}'.format(crack_centuries, p=prec) + eol
    msg += '    Millenia  : {:,.{p}f}'.format(crack_millenia,  p=prec) + eol
    msg += '    Mega-Annum: {:,.{p}f}'.format(crack_millions,  p=prec) + eol
    msg += '    Giga-Annum: {:,.{p}f}'.format(crack_billions,  p=prec) + eol
    msg += eol
    msg += '  Strength: {}'.format(
        'Good' if entropy_total >= min_entropy else 'Poor'
    ) + eol

    return msg

setup.py

This is the installation script to allow pip3 install git+https://<REPO-URL>/makesecret.git or pip3 install . if they've simply downloaded the compressed archive.

#!/usr/bin/env python3

from os.path import join, dirname
from setuptools import setup, find_packages


REQUIRED_PYTHON = (3, 6)


def readme():
    with open(join(dirname(__file__), 'README.md')) as f:
        return f.read()


setup(
    name='makesecret',
    version='0.1.4',
    python_requires='>={}.{}'.format(*REQUIRED_PYTHON),
    description='A generator for secure passwords and passphrases.',
    long_description=readme(),
    long_description_content_type='text/markdown',
    author='<my-name-here>',
    author_email='<my-email-here>',
    maintainer='<my-name-here>',
    maintainer_email='<my-email-here>',
    url='<my-repo-url-here>',
    packages=find_packages(),
    entry_points={
        'console_scripts': [
            'makesecret=makesecret.entrypoints:main'
        ],
    },
    data_files=[
        ('data', ['makesecret/data/eff_wordlist.db'])
    ],
    include_package_data=True,
    keywords='secret password passphrase security cryptography entropy',
    classifiers=(
        # https://pypi.org/classifiers/
        'Development Status :: 1 - Planning',
        'Environment :: Console',
        'Intended Audience :: End Users/Desktop',
        'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
        'Natural Language :: English',
        'Operating System :: OS Independent',
        'Operating System :: POSIX',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
        'Programming Language :: Python :: 3 Only',
        'Topic :: Security',
        'Topic :: Security :: Cryptography',
        'Topic :: Utilities'
    ),
)

Example Runs

A few examples of what the program looks like during use.

$ makesecret -h
usage: makesecret [-h] [--brute-force ATTEMPTS] [--min-entropy BITS]
                  [--precision PLACES]
                  {passphrase,password} ...

A secure password and passphrase generator

optional arguments:
  -h, --help            show this help message and exit
  --brute-force ATTEMPTS
                        assumed guess attempts per second (default:
                        1000000000)
  --min-entropy BITS    minimum amount of entropy bits to consider result
                        acceptable (default: 60)
  --precision PLACES    the number of decimal places to show during analysis
                        (default: 4)

subcommands:
  {passphrase,password}
                        additional help for specific subcommands
    passphrase          help for generating passphrases
    password            help for generating passwords

IMPORTANT: Read the documentation to properly understand how to use this
program. In GNU+Linux, you can use `pydoc3 makesecret`. In Windows you may use
`py -m pydoc makesecret`.

$ makesecret passphrase --analyze
affiliate jovial stingray demotion rectified strut

Facts About Your Result
  Selection Pool            : 7,776
  Entropy (bits per choice) : 12.9248
  Entropy (bits overall)    : 77.5489

Attack: Brute-Force (1,000,000,000 attemps/sec assumed)
  Guess Attempts (avg.): 110,536,959,860,366,712,504,320
  Time to Crack (avg.) :
    Seconds   : 110,536,959,860,366.7188
    Hours     : 30,704,711,072.3241
    Days      : 1,279,362,961.3468
    Weeks     : 182,766,137.3353
    Years     : 3,502,704.8908
    Decades   : 350,270.4891
    Centuries : 35,027.0489
    Millenia  : 3,502.7049
    Mega-Annum: 3.5027
    Giga-Annum: 0.0035

  Strength: Good

$ makesecret password --analyze
E%!ACbedCgm#

Facts About Your Result
  Selection Pool            : 94
  Entropy (bits per choice) : 6.5546
  Entropy (bits overall)    : 78.6551

Attack: Brute-Force (1,000,000,000 attemps/sec assumed)
  Guess Attempts (avg.): 237,960,157,407,128,324,145,152
  Time to Crack (avg.) :
    Seconds   : 237,960,157,407,128.3125
    Hours     : 66,100,043,724.2023
    Days      : 2,754,168,488.5084
    Weeks     : 393,452,641.2155
    Years     : 7,540,502.3642
    Decades   : 754,050.2364
    Centuries : 75,405.0236
    Millenia  : 7,540.5024
    Mega-Annum: 7.5405
    Giga-Annum: 0.0075

  Strength: Good

$ makesecret password --choose 6 --analyze
I,l!O9

Facts About Your Result
  Selection Pool            : 94
  Entropy (bits per choice) : 6.5546
  Entropy (bits overall)    : 39.3275

Attack: Brute-Force (1,000,000,000 attemps/sec assumed)
  Guess Attempts (avg.): 344,934,890,528
  Time to Crack (avg.) :
    Seconds   : 344.9349
    Hours     : 0.0958
    Days      : 0.0040
    Weeks     : 0.0006
    Years     : 0.0000
    Decades   : 0.0000
    Centuries : 0.0000
    Millenia  : 0.0000
    Mega-Annum: 0.0000
    Giga-Annum: 0.0000

  Strength: Poor

Built-in Documentation

I'm interested in trying to get the user to have a better understanding of what makes a generator secure and so on, so this is the built-in documentation that is shown to the user by pydoc3 makesecret.

'''A secrets generator to get strong passwords and passphrases

This program locally generates, but does NOT store, user secrets, such as
passwords and passphrases, in a way that is cryptographically strong and
secure. It is NOT a password manager.

Here are the simplest ways to use it for passwords and passphrases:

    $ makesecret password
    ...
    $ makesecret passphrase
    ...

Here is how to get help on available options:

    $ makesecret --help
    ...
    $ makesecret password --help
    ...
    $ makesecret passphrase --help
    ...

The passphrase is generated from a words list included as part of the
installation of this program. The words list was created by the Electronic
Frontier Foundation (EFF), and can be found at the following URL:
https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases

The security and strength of this generator is maintained even if an
attacker knows any and/or all of the following:

    * the list of words used,
    * the set of alphanumeric characters used,
    * that this program was used to generate the result,
    * what the source code of this program looks like, and/or
    * the amount of entropy produced by the process

Since this program only generates, but does NOT store, the secret, this
means that SECURE STORAGE of the secrets IS YOUR RESPONSIBILITY.

These are the primary use-cases considered for this tool's implementation
and some recommendations for using it in a way that reduces the chances
of you unknowingly compromising your own security. It is assumed that you
have taken, or will take, the following precautions beforehand:

    * the system is under your full control and is not shared/public,
    * the system is not infected with malware or under surveillance,
    * you have positioned your display to avoid shoulder surfers,
    * you are using secure storage (e.g. a password manager, encrypted
      file system, encrypted browser sync services, etc.)

The strength of passwords and passphrases is measurable, in terms of the
amount of uncertainty, unpredictability, or "randomness" that is built
into the process that generates the result, and not by the result itself.
In other words, the strength of a result is not determined by its own
properties such as length, how "complicated" or "random" it may appear to
the human eye, etc. Rather, it is determined by the amount of (Shannon)
Entropy in the process used to generate it.

In general, Shannon Entropy is measured in terms of the number of options
available to choose from, all of which MUST have the same probability of
being chosen. In short, selection must be TRULY random. But this presents
a problem, because computers are not truly random, they're only pseudo-
random because computers are determistic.

This means that using a Cryptographically Secure Pseudorandom Number
Generator (CSPRNG) is really critical, and that anything that changes the
probabilities when choosing alternatives reduces the amount of entropy
that can be obtained and weakens the whole system. It is also based on a
logarithmic scale, using bits as the unit of measure.[1]

For example, consider a fair coin. The coin provides only 2 possible
outcomes, head or tails, so we say that the the amount of entropy `S = 2`,
because that is the total number of possible choices. To convert this to
the actual measurement in bits, we take the base-2 logarithm of 2, i.e.

    S = 2
    E = log₂(S) = 1

which means there is only 1 bit of entropy. Note that this is only for
a single throw. Every additional throw adds 1 more bit of entropy to the
generated sequence, so a sequence of length `N` would have `N` bits of
entropy. Unfortunately, a process with this little entropy would require
too long a sequence to make it practical. But we can do better. We need
to increase the number of equiprobable alternatives, and thus, the number
of entropy bits per selection attempt.

Consider a fair 8-sided die. In this case:

    S = 8
    E = log₂(S) = 3

That is, the are 8 (or 2³) equally probable outcomes for each throw of
the die. And since `2³ = 8` and `log₂(2³) = 3`, then this shows that the
die offers 3 bits of entropy per throw. In more practical terms, this
means that, while in the first example you would need 12 coin throws to
generate a 12-bit sequence, in the second example you can generate a
sequence with the same 12-bit "strength" with only 4 throws of the die.

By default, `makesecret` generates passwords that are 12 characters long
from a set of 94 symbols including lower/upper-case letters, digits, and
special characters (e.g. punctuation). This means:

    S = 94
    E = log₂(S) ≈ 6.6

That is, there are about 6.6 bits of entropy per selection. Since the
default length is 12 characters, that means the total amount of entropy
bits is:

    E = 6.6 * 12 ≈ 78.7

which, assuming 1 Billion attempts per second, would take an average of
7.5 Million years to brute-force.

On the other hand, things are different when generating passphrases. The
passphrases are based on a list of common words with 7776 entries in it.
This means:

    S = 7776
    E = log₂(S) ≈ 12.9

showing about 12.9 bits of entropy per selection. Since the default
number of words is 6, that means the total amount of entropy is:

    E = 6 * 12.9 ≈ 77.4

which, under the same assumptions as before, would take an average of
3.5 Million years to brute-force. If you think six words is too much to
remember, you can choose a different number. For example:

    $ makesecret passphrase --choose 5

The consequence of this would be:

    E = 5 * 12.9 ≈ 64.6

requiring an average of 4.5 centuries to crack under the same
conditions. To display an analysis of your results, you can use:

    $ makesecret ... --analyze

Note that all of this relies on selections being made randomly. If you
generate a result using a non-random method or a PRNG that is not Crypto-
graphically Secure, then the strength of your result is going to go down
significantly. In fact, it could have no entropy/strength at all, being
completely predictable; e.g. "correct horse battery staple"[2].


[1] This is similar to how the Richter Magnitude Scale for earthquakes
works, but using base-2 logarithms instead of base-10 logarithms. What
this means is that, in the same way that an earthquake rated as a 5 in
the scale is 10 times stronger than a 4 and 100 times stronger than a 3
(because it is base-10), a process with 5 bits of entropy has twice as
many choices as one with 4 bits and four times as many choices as one
with 3 bits (because it is base-2). In other words, for every bit of
entropy that you want to add, you must duplicate the total number of
possible choices and make sure that every choice has the same odds of
of being selected.

[2] https://xkcd.com/936

'''
\$\endgroup\$
  • \$\begingroup\$ Since this was posted, I've moved the Secret class from the providers module over to a new models module, i.e. models.Secret, and updated the rest of the codebase. \$\endgroup\$ – code_dredd Oct 15 '18 at 16:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.