--- /dev/null
+import string
+import argparse
+import sqlite3
+import sys
+import contextlib
+import random
+
+ap = argparse.ArgumentParser(description = "Generate a random password")
+special = ''.join(string.punctuation).translate({ord('%'): '%%'})
+arg_specs = [
+ (['-db'], {'help': "SQLite connect string (e.g. file path) to word list database"}),
+ (['-t'], {'help': "Type of password to generate", 'choices': ['chbs', 'chars']}),
+ (['-ll'], {'help': "Require this many lowercase letters in the password", 'type': int}),
+ (['-ul'], {'help': "Require this many uppercase letters in the password", 'type': int}),
+ (['-d'], {'help': "Require this many digits in the password", 'type': int}),
+ (['-s'], {'help': "Require this many special characters in the password ({})".format(special), 'type': int}),
+ (['-mxl'], {'help': "Maximum password length", 'type': int}),
+ (['-mnl'], {'help': "Minimum password length", 'type': int}),
+]
+for (pos, kw) in arg_specs:
+ ap.add_argument(*pos, **kw)
+
+class ChbsGenerator:
+ '''Generate a Correct Horse Battery Staple password (xkcd.com/936)'''
+
+ WORD_CT = 4
+
+ def __init__(self, conn_str, rand_src):
+ self.conn_str = conn_str
+ self.rand_src = rand_src
+
+ def __enter__(self):
+ self.conn = sqlite3.connect(self.conn_str)
+ return self
+
+ def __exit__(self, *_):
+ self.conn.close()
+
+ def _get_word(self):
+ with contextlib.closing(self.conn.cursor()) as curs:
+ (word_ct,) = curs.execute('select count(*) from word;').fetchone()
+ index = self.rand_src.randint(1, word_ct)
+ (word,) = curs.execute('select word from word where rowid = ?;', (index,)).fetchone()
+ return word
+
+ def generate(self):
+ return ''.join(self._get_word().title() for _ in range(ChbsGenerator.WORD_CT))
+
+class CharsGenerator:
+ TARGET_LEN = 12
+ ALL_CHARS = ''.join([
+ string.ascii_lowercase,
+ string.ascii_uppercase,
+ string.digits,
+ string.punctuation,
+ ])
+
+ def __init__(self, args, random_src):
+ self.opts = args
+ self.random_src = random_src
+
+ def _clamp(self, length):
+ if self.opts.mnl is not None:
+ length = max(length, self.opts.mnl)
+ if self.opts.mxl is not None:
+ length = min(length, self.opts.mxl)
+ return length
+
+ def generate(self):
+ r = self.random_src
+ chars = []
+ if self.opts.ll is not None:
+ chars.extend((r.choice(string.ascii_lowercase) for _ in range(self.opts.ll)))
+ if self.opts.ul is not None:
+ chars.extend((r.choice(string.ascii_uppercase) for _ in range(self.opts.ul)))
+ if self.opts.d is not None:
+ chars.extend((r.choice(string.digits) for _ in range(self.opts.d)))
+ if self.opts.s is not None:
+ chars.extend((r.choice(string.punctuation) for _ in range(self.opts.s)))
+ length = self._clamp(CharsGenerator.TARGET_LEN)
+ left = length - len(chars)
+ chars.extend((r.choice(CharsGenerator.ALL_CHARS) for _ in range(left)))
+ r.shuffle(chars)
+ return ''.join(chars)
+
+def safe_sum(iterable):
+ return sum(x for x in iterable if x is not None)
+
+def are_sane(args):
+ if args.mxl is not None and args.mxl < 0:
+ return False
+ if args.mnl is not None and args.mnl < 0:
+ return False
+ if args.mxl is not None and args.mnl is not None:
+ if args.mxl < args.mnl:
+ return False
+ if args.mxl is not None and safe_sum([args.ll, args.ul, args.d, args.s]) > args.mxl:
+ return False
+ if args.t == 'chbs':
+ if not (args.d is None and args.s is None):
+ return False
+ if args.ul is not None and args.ul > ChbsGenerator.WORD_CT:
+ return False
+ return True
+
+def choose_type(args):
+ if args.t is not None:
+ return (args.t, True)
+ elif args.d is None and args.s is None:
+ return ('chbs', False)
+ else:
+ return ('chars', False)
+
+def validate(password, args):
+ def count(chars):
+ return sum(c in chars for c in password)
+
+ if args.mnl is not None and len(password) < args.mnl:
+ return False
+ if args.mxl is not None and len(password) > args.mxl:
+ return False
+ if args.ll is not None and count(string.ascii_lowercase) < args.ll:
+ return False
+ if args.ul is not None and count(string.ascii_uppercase) < args.ul:
+ return False
+ if args.d is not None and count(string.digits) < args.d:
+ return False
+ if args.s is not None and count(string.punctuation) < args.s:
+ return False
+ return True
+
+args = ap.parse_args()
+if not are_sane(args):
+ print("Password requirements are inconsistent", file = sys.stderr)
+ sys.exit(1)
+
+(type_, manual) = choose_type(args)
+if type_ == 'chbs':
+ if args.db is None:
+ if manual:
+ print("CHBS was chosen but no word source was specified.", file = sys.stderr)
+ sys.exit(1)
+ else:
+ type_ = 'chars'
+ else:
+ # Try this many times to create a compliant CHBS password.
+ # Note that trying to generate a CHBS password within a specific length range
+ # is difficult and could introduce non-obvious entropy issues.
+ # So we just try generating a few and checking them.
+ # In practice, this should hopefully succeed if and only if the length range
+ # is secure.
+ TRIES = 5
+
+ with ChbsGenerator(args.db, random.SystemRandom()) as gen:
+ for _ in range(TRIES):
+ cand = gen.generate()
+ if validate(cand, args):
+ password = cand
+ break
+ else:
+ password = None
+ if password is not None:
+ print(password)
+ else:
+ if manual:
+ print("Unable to generate a CHBS password satisfying your requirements (see code).", file = sys.stderr)
+ sys.exit(1)
+ else:
+ type_ = 'chars'
+if type_ == 'chars':
+ gen = CharsGenerator(args, random.SystemRandom())
+ print(gen.generate())