6
\$\begingroup\$

I wanted to write a commandline scipt to generate diceware passwords based on the EFF diceware list.

The script (diceware_password.py) can be found below. It was originally developed for Python 2, but I want it to be compatible with Python 3 as well (both versions have been tested on my machine). Running it with no arguments will generate a 6 word password as recommended by the EFF. Additionally, you may also input dice throws made in the "real world".
Note: You need to download the EFF's large wordlist for the script to work. It assumes the default filename, but you can pass other filenames using the --wordlist flag.

Any feedback is very welcome, especially on the clarity of the code, as well as Python 2/3 compatibility best practices.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function

import random
import codecs
import argparse

import six.moves


def large_wordlist_from_file(fname, sep="\t"):
    """Read an EFF compatible large wordlist from file"""
    word_dict = {}
    with codecs.open(fname, encoding="utf-8", mode="rb") as file_:
        while True:
            line = file_.readline()
            if line:
                key, word = line.strip().split(sep)
                word_dict[tuple(int(i) for i in key)] = word
            else:
                break
    return word_dict


def roll_dices(nrolls, ndices=5):
    """Rolls a number of dices (default: 5) n times"""
    rng = random.SystemRandom()
    return tuple(
        tuple(rng.randrange(1, 7) for _ in six.moves.range(ndices)) for _ in six.moves.range(nrolls)
    )


def validate_dice_rolls(roll_sequences):
    """Validate dice sequences from user input"""
    rolls = []
    for roll_sequence in roll_sequences:
        roll = []
        for throw in roll_sequence:
            if not (1 <= int(throw) <= 6):
                raise ValueError("Each throw must be between 1 and 6.")
            roll.append(int(throw))
        if len(roll) != 5:
            raise ValueError("Each roll sequence has to be of length 5 "
                             "to work with the EFF wordlist!")
        rolls.append(tuple(roll))
    return rolls

def get_interactive_dice_rolls():
    """Enter your dice throws on the command line"""
    rolls = []
    while True:
        user_input = six.moves.input("Please enter your dice sequences: ")
        roll_sequence = user_input.strip().split(" ")
        try:
            rolls = validate_dice_rolls(roll_sequence)
            break
        except ValueError as ex:
            print("Failed to parse your input for the following reason: "+ex.message)
            user_input = six.moves.input("Do you want to try again? (y/n) ")
            user_input = user_input.strip().lower()
            if user_input.startswith("n"):
                break
    return rolls


def main():
    """Commandline script to generate diceware passwords"""
    parser = argparse.ArgumentParser(
        description="Generate diceware passwords with N words. "
                    "EFF recommendation is N >= 6."
    )
    parser.add_argument(
        "n", metavar="N", type=int, nargs="?", default=6,
        help="number of diceware words (default: 6)",
    )
    parser.add_argument(
        "--real-dice", type=str, nargs="*", metavar="DICE_SEQUENCE",
        help="instead of using PRNG, use your given dice throws. "
             "Please group them in N DICE_SEQUENCEs of length 5 seperated "
             "by a space"
    )
    parser.add_argument(
        "--wordlist", type=str, nargs="?", default="eff_large_wordlist.txt",
        help="The diceware wordlist to use. This script assumes the list to "
             "contain one word per line where each word is preceeded by its "
             "'dice' index. Index and word are separated by a single tab. "
             "(default: \"eff_large_wordlist.txt\")"
    )
    parser.add_argument(
        "--interactive", action="store_true", default=False,
        help="Interactive mode allows you to enter your dice sequences via "
             "command prompt in order to avoid traces in your bash history"
        )
    args = parser.parse_args()

    words = large_wordlist_from_file(args.wordlist, "\t")
    rolls = []
    if args.real_dice is not None:
        rolls = validate_dice_rolls(args.real_dice)
    elif args.interactive:
        rolls = get_interactive_dice_rolls()
    else:
        rolls = roll_dices(args.n, 5)

    if rolls:
        # print the result
        try:
            print(" ".join(words[roll] for roll in rolls))
        except KeyError:
            print(list(words.keys()))


if __name__ == "__main__":
    main()

\$\endgroup\$
3
\$\begingroup\$

What threw me off for a short while is your usage of six.moves.range, but I see now that it is for compatibility. However, you can make this usage more transparent by importing it like this:

from six.moves import range

I would also use random.SystemRandom().randint instead of randrange because it is inclusive and makes it that slight bit more readable. Note "dice" is already the plural of "die", "dices" is not an English word.

RNG = random.SystemRandom()

def roll_dice(nrolls, ndice=5):
    """Rolls a number of dice (default: 5) n times"""
    return [[RNG.randint(1, 6) for _ in range(ndice)] for _ in range(nrolls)]

I also made the random number generator a global constant, no need to redefine that every time you roll the dice and made it list comprehensions instead of tuples on generator expressions, mostly so it fits into one line.

\$\endgroup\$
2
\$\begingroup\$

Overall this looks pretty good. One very important problem: you are using random to generate the random numbers. Python documetation has the important warning:

Warning

The pseudo-random generators of this module should not be used for security purposes. For security or cryptographic uses, see the secrets module.

To fix this, you should use secrets which provides a randbelow function.

\$\endgroup\$
  • 3
    \$\begingroup\$ I used SystemRandom which was, if I correctly understood the doc, the way to go in Python 2. secrets seems to be new in Python 3. \$\endgroup\$ – AlexV Mar 25 at 8:44
  • \$\begingroup\$ Hmm, then you should probably have an if else for python2 vs 3 there. Secrets is almost certainly better than SystemRandom. \$\endgroup\$ – Oscar Smith Mar 25 at 15:02
  • 1
    \$\begingroup\$ Seems like the secret module's implementation also just uses random.SystemRandom in Python 3.7. \$\endgroup\$ – AlexV Mar 25 at 15:46
  • 5
    \$\begingroup\$ @OscarSmith Nope, secrets is SystemRandom. Ones of the first few lines in secrets are from random import SystemRandom; _sysrand = SystemRandom() \$\endgroup\$ – Mathias Ettinger Mar 25 at 15:46

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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