3
\$\begingroup\$

After giving a couple of well-received answers to similar questions, I thought I'd try to come up with my own solution.

I believe that a good password generator can and should be quite simple without sacrificing security or usability.

Also, this is the first time I actually code a command-line utility (as a Windows user, command-line tools aren't part of my usual work-flow).

I'm interested in any remark about my code, especially regarding security and features.

'''
Command-line utility program for generating passwords
'''

from argparse import ArgumentParser, RawTextHelpFormatter
import secrets
from string import ascii_lowercase, ascii_uppercase, digits, punctuation


ESCAPES = { r'\l' : ascii_lowercase,
            r'\u' : ascii_uppercase,
            r'\d' : digits,
            r'\s' : punctuation }


def generate_password(length, alphabet):
    return ''.join(secrets.choice(alphabet) for _ in range(length))


def _parse_alphabet(specifiers):
    ranges = []
    for escape, characters in ESCAPES.items():
        if escape in specifiers:
            ranges.append(characters)
            specifiers = specifiers.replace(escape, '')
    ranges.append(specifiers)
    return ''.join(set(''.join(ranges)))


if __name__ == '__main__':
    parser = ArgumentParser(
        prog='Password generator',
        formatter_class=RawTextHelpFormatter)
    parser.add_argument(
        '-l', '--length',
        default=32,
        type=int,
        help='length of password to be generated')
    parser.add_argument(
        '-a', '--alphabet',
        default=r'\l\u\d\s',
        help=r'''alphabet to use for password generation. The following escape sequences are supported:
    * \u: uppercase ASCII letters.
    * \l: lowercase ASCII letters.
    * \d: digits.
    * \s: other printable ASCII characters.
Any other specified character will also be included.''')
    args = parser.parse_args()
    print(generate_password(args.length, _parse_alphabet(args.alphabet)))

Example uses:

# show help
(base) [...]>python password.py -h
usage: Password generator [-h] [-l LENGTH] [-a ALPHABET]

optional arguments:
  -h, --help            show this help message and exit
  -l LENGTH, --length LENGTH
                        length of password to be generated
  -a ALPHABET, --alphabet ALPHABET
                        alphabet to use for password generation. The following escape sequences are supported:
                            * \u: uppercase ASCII letters.
                            * \l: lowercase ASCII letters.
                            * \d: digits.
                            * \s: other printable ASCII characters.
                        Any other specified character will also be included.

# generate a password with default settings:
(base) [...]>python password.py
y3S%iR!O=zNhcWYf-f9@87m_FC'WGr+^

# generate a 6-digit PIN (eg. for phone unlocking):
(base) [...]>python password.py -l 6 -a \d
310696

# generate a password with smaller length and only some special chars (eg. for website compatibility):
(base) [...]>python password.py -l 16 -a "\u\l\d_-&@!?"
bP7L?_uuGbdHFww@

# generate a long password:
(base) [...]>python password.py  -l 250
FO_Hy1O,7#z}M'5K/,=:Al"dK-w{VIC:G$[Xqm|GRw8Uou@d^peD1vk$:76)n#28f112w"vz<F+-c\it$7@fb9Xq@3\[)7+*R7PTa(gdMb&\,?n6GRT93jD'L!6Ww)u:-}rWyXq&?V!pw>9<&.Nt4S!+a9X6f~z+?DZeB:);`-!{K;ftB(/;"TEeKi%yV,-,H3{2!x2Y"]'?[3$/'QM=K5mSo[!D~#;!iuY]=BF3=LLkGK92Nm4kttc\*S

# generate a password with non-ASCII characters:
(base) [...]>python password.py -a µùûÜ
ùùùÜùùµÜܵµµÜùûÜûµÜÜÜûûÜûûùùùûܵ
\$\endgroup\$
2
  • \$\begingroup\$ I can't find many flaws to your code, except for one. Most websites require passwords to contain at least one character from each of the catergories: lowercase, UPPERCASE and digits, with optional special characters. The first three must be contained by the password, and yet your code doesn't guarantee that the password will contain all three, it generates invalid passwords. So it needs to be fixed. \$\endgroup\$ May 25, 2023 at 4:22
  • \$\begingroup\$ Good point. I considered the issue, but the issue is that there are about as many different password validation rules than there are websites, with variations like "characters from 3 out of 4 character subsets", and handling them made usage too complex (especially combined with supporting only a subset of special chars, which is a very prevalent constraint). I implicitly rely on the fact that, given a long enough passwords, meeting any requirement of this type is highly probable. If it fails, generate a new password. \$\endgroup\$
    – gazoh
    May 25, 2023 at 11:11

1 Answer 1

3
\$\begingroup\$

Configuring argparse is real code: put it in a function. There is nothing trivial about configuring argparse. Put the code in a proper function -- for maintainability, flexibility, testability, etc.

Pass the args explicitly to argparse. Don't rely on its default use of sys.argv. This is another testability consideration.

When feasible, avoid RawTextHelpFormatter. It causes your program's help text to ignore things like dynamic terminal widths. In this case, a little wordsmithing can work around the perceived need for it. With program help text (especially for arguments and options), brevity is crucial. When facing complexities needing further elaboration, keep the argument help texts brief and rely on an argparse epilog for further explanation.

Speaking of brevity, consider using argparse metavar. It can reduce the visual weight/clutter of the generated help text.

List the long options first. It enhances help text readability.

Define constants for the default alphabet and length. You need to use each in two places.

A simpler alternative for _parse_alphabet(). Perhaps I overlooked something, but I think you can start with the user-supplied alphabet and just replace all of the escapes. In the illustration below, sorted() is neither necessary nor harmful for the password algorithm, but it is helpful for any debugging/testing you might want to perform.

import sys
import secrets

from argparse import ArgumentParser
from string import ascii_lowercase, ascii_uppercase, digits, punctuation

ESCAPES = {
    r'\l': ascii_lowercase,
    r'\u': ascii_uppercase,
    r'\d': digits,
    r'\s': punctuation,
}

DEFAULT_ALPHABET = ''.join(ESCAPES.keys())
DEFAULT_LENTH = 32

def main(args):
    opts = parse_args(args)
    alpha = _parse_alphabet(opts.alphabet)
    p = generate_password(opts.length, alpha)
    print(p)

def parse_args(args):
    p = ArgumentParser(prog = 'Password generator')
    p.add_argument(
        '--length', '-l',
        default = DEFAULT_LENTH,
        type = int,
        metavar = 'N',
        help = f'password length [default: {DEFAULT_LENTH}]',
    )
    p.add_argument(
        '--alphabet', '-a',
        default = DEFAULT_ALPHABET,
        metavar = 'A',
        help = (
            r'password alphabet. Supported escapes: '
            r'\u upper ASCII, \l lower ASCII, \d digits, '
            rf'\s other printable ASCII [default: "{DEFAULT_ALPHABET}"]'
        ),
    )
    return p.parse_args(args)

def _parse_alphabet(alpha):
    for e, chars in ESCAPES.items():
        alpha = alpha.replace(e, chars)
    return ''.join(sorted(set(alpha)))

def generate_password(length, alphabet):
    return ''.join(secrets.choice(alphabet) for _ in range(length))

if __name__ == '__main__':
    main(sys.argv[1:])
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.