From 7de13ab451623b0066c99947d382a41273ee3ebd Mon Sep 17 00:00:00 2001 From: Jakob Cornell Date: Wed, 18 Sep 2019 17:32:31 -0500 Subject: [PATCH 1/1] Initial commit --- gen_pass.py | 172 +++++++++++++++++++++++++++++++++++++++++++++++++++ mk_sqlite.py | 30 +++++++++ 2 files changed, 202 insertions(+) create mode 100644 gen_pass.py create mode 100644 mk_sqlite.py diff --git a/gen_pass.py b/gen_pass.py new file mode 100644 index 0000000..d7fbb35 --- /dev/null +++ b/gen_pass.py @@ -0,0 +1,172 @@ +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()) diff --git a/mk_sqlite.py b/mk_sqlite.py new file mode 100644 index 0000000..b9ff368 --- /dev/null +++ b/mk_sqlite.py @@ -0,0 +1,30 @@ +import argparse +import sqlite3 +import contextlib + +ap = argparse.ArgumentParser(description = "Extract words from a Unix word list file and store in an SQLite database") +arg_specs = [ + (['wordlist'], {'help': 'path to word list'}), + (['database'], {'help': 'SQLite connect string, e.g. path to file'}), +] +for (pos, kw) in arg_specs: + ap.add_argument(*pos, **kw) + +args = ap.parse_args() + +def test_word(word): + return word.isascii() and word.isalnum() + +with sqlite3.connect(args.database) as conn: + with contextlib.closing(conn.cursor()) as curs: + curs = conn.cursor() + curs.execute('create table word (word);') + with open(args.wordlist) as f: + line_ct = 0 + for line in f: + line_ct += 1 + word = line.strip() + if test_word(word): + curs.execute('insert into word values (?);', (word,)) + (word_ct,) = curs.execute('select count(*) from word;').fetchone() + print('Stored {} of {} words'.format(word_ct, line_ct)) -- 2.30.2