From 932322011b88045df655bcee174b9aabe659ba5c Mon Sep 17 00:00:00 2001 From: Fredrik Eriksson Date: Sun, 22 Jan 2017 18:17:29 +0100 Subject: [PATCH] First version of pwgen --- bin/pwgen | 115 +++++++++++++++++++++++++++ pwgen/__init__.py | 194 ++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 34 ++++++++ 3 files changed, 343 insertions(+) create mode 100644 bin/pwgen create mode 100644 pwgen/__init__.py create mode 100644 setup.py diff --git a/bin/pwgen b/bin/pwgen new file mode 100644 index 0000000..5bf7562 --- /dev/null +++ b/bin/pwgen @@ -0,0 +1,115 @@ +#!/usr/bin/python3 +import argparse +import os +import sys + +import pwgen + +default_config_file=os.path.expanduser('~/.pwgen.cfg') + +def main(): + + parser = argparse.ArgumentParser(description='Generate passwords') + parser.add_argument( + '--generate-config', '-g', + action='store_true', + help='Generate configuration file and then exit') + parser.add_argument( + '--config-file', '-c', + help='Configuration file to use', + default=default_config_file) + + parser.add_argument( + '--myspell-dir', '-i', + help='Directory containing myspell dictionaries') + + parser.add_argument( + '--lang', '-l', + help='Dictionary language to use') + parser.add_argument( + '--word-min-char', '-m', + type=int, + help='Minimum number of characters in a word') + parser.add_argument( + '--word-max-char', '-M', + type=int, + help='Maximum number of characters in a word') + + parser.add_argument( + '--words', '-w', + type=int, + help='Number of words to use in the passphrase') + parser.add_argument( + '--capitalize', '-C', + choices=('true', 'false', 'random'), + help='Capitalize the words') + parser.add_argument( + '--separators', '-s', + help='Possible characters to use as separators') + parser.add_argument( + '--trailing-digits', '-d', + type=int, + help='Number of digits at the end of the passphrase') + parser.add_argument( + '--leading-digits', '-D', + type=int, + help='Number of digits at the start of the passphrase') + parser.add_argument( + '--special-chars', '-S', + help='Possible characters to use as extra special characters') + parser.add_argument( + '--trailing-chars', '-p', + type=int, + help='Number of special characters to add at the end of the passphrase') + parser.add_argument( + '--leading-chars', '-P', + type=int, + help='Number of special characters to add at the start of the passphrase') + parser.add_argument( + '--passwords', '-n', + type=int, + help='Number of passwords to generate') + parser.add_argument( + '--max-length', '-L', + type=int, + help="Maximum length of the generated passwords. Full-knowledge "\ + "entropy calculation doesn't work when this is set.") + + + + args = vars(parser.parse_args()) + + config_file = args['config_file'] + del args['config_file'] + + if not os.path.isfile(config_file): + print("Missing configuration file; generating a new at {}".format(config_file)) + conf = pwgen.update_config() + pwgen.save_config(config_file, conf) + + conf = pwgen.get_config(config_file) + save_config = args['generate_config'] + del args['generate_config'] + + conf = pwgen.update_config(config=conf, **args) + + if save_config: + print("Updating configuration file at {}".format(config_file)) + pwgen.save_config(config_file, conf) + sys.exit(0) + + pwds, seen_entropy = pwgen.generate_passwords(conf) + + print("Generated {} passwords".format(len(pwds))) + if seen_entropy: + print("Full-knowledge entropy is {0:.2g}".format(seen_entropy)) + else: + print("Unable to calculate full-knowledge entropy since max_length is used") + print("Blind entropy\tPassword") + print("========================") + for pw in pwds.keys(): + print("{:.5n}\t\t{}".format(pwds[pw]['entropy'], pw)) + print("========================") + +if __name__ == '__main__': + main() diff --git a/pwgen/__init__.py b/pwgen/__init__.py new file mode 100644 index 0000000..a922269 --- /dev/null +++ b/pwgen/__init__.py @@ -0,0 +1,194 @@ +#!/usr/bin/python3 + +import math +import os +import sys +#import secrets as random +import random + +if sys.version_info[0] < 3: + import ConfigParser as configparser +else: + import configparser + +if sys.version_info >= (3, 6): + do_seed = False + import secrets as random +else: + do_seed = True + import random + +class PasswordLengthError(Exception): + pass + +def save_config(target_file, config): + path = os.path.expanduser(target_file) + with open(path, 'w') as f: + config.write(f, False) + +def update_config( + config=None, + + myspell_dir='/usr/share/hunspell', + lang='en_US', + word_min_char=2, + word_max_char=0, + + words=4, + capitalize='random', + separators=',.- _=:', + trailing_digits=2, + leading_digits=0, + special_chars='!?.,:-_$/@', + trailing_chars=1, + leading_chars=0, + passwords=5, + max_length=0 + ): + if not config: + conf = configparser.ConfigParser() + else: + conf = config + + def set_if_defined(conf, section, var, val): + if val is not None: + conf.set(section, var, str(val)) + + if not conf.has_section('dictionary'): + conf.add_section('dictionary') + + set_if_defined(conf, 'dictionary', 'myspell_dir', myspell_dir) + set_if_defined(conf, 'dictionary', 'lang', lang) + set_if_defined(conf, 'dictionary', 'word_min_char', word_min_char) + set_if_defined(conf, 'dictionary', 'word_max_char', word_max_char) + + if not conf.has_section('passwords'): + conf.add_section('passwords') + set_if_defined(conf, 'passwords', 'words', words) + set_if_defined(conf, 'passwords', 'capitalize', capitalize) + set_if_defined(conf, 'passwords', 'separators', separators) + set_if_defined(conf, 'passwords', 'trailing_digits', trailing_digits) + set_if_defined(conf, 'passwords', 'leading_digits', leading_digits) + set_if_defined(conf, 'passwords', 'special_chars', special_chars) + set_if_defined(conf, 'passwords', 'trailing_chars', trailing_chars) + set_if_defined(conf, 'passwords', 'leading_chars', leading_chars) + set_if_defined(conf, 'passwords', 'passwords', passwords) + set_if_defined(conf, 'passwords', 'max_length', max_length) + return conf + + +def get_config(f_name): + conf = configparser.ConfigParser() + conf.read(f_name) + return conf + +def _read_dictionary(f_name, word_min_chars, word_max_chars): + words = set() + chars = 0 + with open(f_name, 'r') as f: + for line in f: + if not line: + continue + first_char = line[0] + if first_char in '1234567890,.-:': + continue + word = line.split('/', 1)[0] + word = word.strip() # remove newlines + last_char = word[-1] + if last_char in '-': + continue + if word_min_chars and len(word) < word_min_chars: + continue + if word_max_chars and len(word) > word_max_chars: + continue + words.add(word) + chars += len(word) + return {'words': list(words), 'wordlength': int(chars/len(words))} + + +def generate_passwords(conf): + + nr_of_words = conf.getint('passwords', 'words') + nr_of_ld = conf.getint('passwords', 'leading_digits') + nr_of_td = conf.getint('passwords', 'trailing_digits') + nr_of_lc = conf.getint('passwords', 'leading_chars') + nr_of_tc = conf.getint('passwords', 'trailing_chars') + special_chars = conf.get('passwords', 'special_chars') + separators = conf.get('passwords', 'separators') + capitalize = conf.get('passwords', 'capitalize') + max_len = conf.getint('passwords', 'max_length') + + if do_seed: + random.seed() + res = {} + dict_data = _read_dictionary( + os.path.join(conf.get('dictionary', 'myspell_dir'), '{}.dic'.format(conf.get('dictionary', 'lang'))), + conf.getint('dictionary', 'word_min_char'), + conf.getint('dictionary', 'word_max_char')) + + words = dict_data['words'] + word_length = dict_data['wordlength'] + approx_pwd_length = nr_of_words*word_length+nr_of_ld+nr_of_td+nr_of_lc+nr_of_tc+nr_of_words-1 + if max_len and approx_pwd_length > max_len: + raise PasswordLengthError( + "Password would be ~{} charactes long but max_len is {}; "\ + "please adjust max_lenght, words and/or the "\ + "trailing/leading settings.".format( + approx_pwd_length, + max_len) + ) + + capitalize_entropy = 1 + if capitalize == 'random': + capitalize_entropy = 2 + # seen entropy is calculated from the password rules. + # At the moment setting max_length breaks this calculation as not all + # combinations of words are possible. + if max_len: + seen_entropy = False + else: + seen_entropy = math.log( + len(words)**nr_of_words *\ + len(separators) *\ + 10**nr_of_ld *\ + 10**nr_of_td *\ + len(special_chars)**nr_of_lc *\ + len(special_chars)**nr_of_tc *\ + capitalize_entropy + , 2) + + while len(res) < conf.getint('passwords', 'passwords'): + separator = random.choice(separators) + my_words = [] + + for i in range(nr_of_words): + word = random.choice(words) + if capitalize == 'random': + if random.choice((True, False)): + word = word.capitalize() + elif capitalize == 'true': + word = word.capitalize() + my_words.append(word) + + base_pwd = separator.join(my_words) + leading_digits = ''.join(random.choice('1234567890') for i in range(nr_of_ld)) + trailing_digits = ''.join(random.choice('1234567890') for i in range(nr_of_td)) + leading_chars = ''.join(random.choice(special_chars) for i in range(nr_of_lc)) + trailing_chars = ''.join(random.choice(special_chars) for i in range(nr_of_tc)) + pwd = '{ld}{lc}{base}{tc}{td}'.format( + lc=leading_chars, + ld=leading_digits, + base=base_pwd, + td=trailing_digits, + tc=trailing_chars) + # blind entropy is calculated only by the number of unique characters + # and password length + blind_entropy = math.log(len(''.join(set(pwd)))**len(pwd), 2) + + if max_len and len(pwd) > max_len: + continue + res[pwd] = { + 'length': len(pwd), + 'entropy': blind_entropy, + } + return (res, seen_entropy) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..739c9a7 --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +from os import environ + +try: + from setuptools import setup +except ImportError: + from distutils import setup + +import pwgen + +version = '0.1' + +setup( + name='pwgen', + version=str(version), + description="Passphrase generator", + author="Fredrik Eriksson", + author_email="pwgen@wb9.se", + url="https://github.com/fredrik-eriksson/pwgen", + platforms=['any'], + license='BSD', + packages=['pwgen'], + classifiers=[ + 'Development Status :: 1 - Planning', + 'Environment :: Console', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 3', + 'Topic :: Utilities', + ], + keywords='password passphrase', + scripts=['bin/pwgen'] + )