Initial commit
authorJakob Cornell <jakob@jcornell.net>
Wed, 18 Sep 2019 22:32:31 +0000 (17:32 -0500)
committerJakob Cornell <jakob@jcornell.net>
Wed, 18 Sep 2019 22:32:31 +0000 (17:32 -0500)
gen_pass.py [new file with mode: 0644]
mk_sqlite.py [new file with mode: 0644]

diff --git a/gen_pass.py b/gen_pass.py
new file mode 100644 (file)
index 0000000..d7fbb35
--- /dev/null
@@ -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 (file)
index 0000000..b9ff368
--- /dev/null
@@ -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))