Work the Shell: the LJ Password Generator Tool

Mnemonic passwords generally stink. A random sequence of letters, digits and punctuation is more secure—just don't write down your passwords, like the knucklehead antagonist does in Ready Player One! By Dave Taylor

In the password generating tool from my last article, at its most simple, you specify the number of characters you want in the password, and each is then chosen randomly from a pool of acceptable values. With the built-in RANDOM in the Linux shell, that's super easy to do:


okay="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
okay="${okay}0123456789<>/?,>;:[{]}\|=+-_)(^%$#@!~
length=10
ltrs=${#okay}

while [ $length -ge 0 ]
do
   letter="${okay:$RANDOM % $ltrs:1}"
   result="$result$letter"
   length=$(( $length - 1 ))
done

echo "Result: $result"

In the actual script, I set okay to a single value rather than build it in two steps; this is just for formatting here online. Otherwise, ltrs is set to the length of $okay as a speedy shortcut, and the result is built up by using the string slicing syntax of:


${variable:indexlocation:length}

To extract just the fourth character of a string, for example, ${string:4:1}, this works fine and is easy. The result speaks for itself:


$ sh lazy-passwords.sh
Result: Ojkr9>|}dMr

And, a few more:


Result: Mi8]TfJKVaH
Result: >MWvF2D/R?r
Result: h>J6\p4eNPH
Result: KixhCFZaesr

Where this becomes a more complex challenge is when you decide you don't want to have things randomly selected but instead want to weight the results so that you have more letters than digits, or no more than a few punctuation characters, even on a 15–20 character password.

Which is, of course, exactly what I've been building.

I have to admit that there's a certain lure to making something complex, if nothing else than just to see if it can be done and work properly.

Adding Weight to Letter Choices

As a result, the simple few lines above changed to this in my last article:


while [ ${#password} -lt $length ] ; do
   case $(( $RANDOM % 7 )) in
     0|1 ) letter=${uppers:$(( $RANDOM % ${#uppers})):1} ;;
     2|3 ) letter=${lowers:$(( $RANDOM % ${#lowers})):1} ;;
     4|5 ) letter=${punct:$(( $RANDOM % ${#punct}  )):1} ;;
     6 ) letter=${digits:$(( $RANDOM % ${#digits}  )):1} ;;
   esac
  password="${password}$letter"
done

In the above code, I'm assigning probability that a given letter will be one of four classes: uppercase, lowercase, punctuation or digits. The first three are each 2:7 chances, and punctuation is 1:7, or half as likely to be produced.

A run of four iterations of the above algorithm produces these results:


q.x0bAPmZb
P}aWX2N-U]
5jdI&ep7rt
-k:TA[1I!3

Since random is, well, random, in both situations, you actually can end up with a password that includes no punctuation. So, how do you force a specific number of occurrences of punctuation symbols? One solution is to check after the fact to see if you met your target count.

To do that, you'll need two things: goals and counters. Let's add the former as startup options in a typical getops block:


while getopts "l:d:p:" arg
do
   case "$arg" in
     l) length=$OPTARG ;;
     d) digitGoal=$OPTARG ;;
     p) punctGoal=$OPTARG ;;
     *) echo "Valid -l length, -d digits, -p punctuation"
        exit 1 ;;
   esac
done

The counters are also easy; every time a digit or punctuation condition is met in the case statement (shown earlier), you increment the counter by one.

Finally, at the end, you can compare the two and see if you met your weighted randomization goals:


if [ $digitsAdded -lt $digitGoal ] ; then
  echo "Didn't add enough digits. [goal = $digitGoal,    
    inserted = $digitsAdded]"
  exit 1
elif [ $punctAdded -lt $punctGoal ] ; then
  echo "Didn't add enough punctuation. [goal = $punctGoal, 
    inserted = $punctAdded]"
  exit 1
fi

If the total length of the requested password is reasonable compared to the random chance that a digit or punctuation character will be added, this will work fine. A 15-character password with at least two punctuation characters will be generated without a hiccup almost every single time.

Although once in a while:


$ sh makepw.sh -l 15 -p 4
Didn't add enough punctuation. [goal = 4, inserted = 1]

This begs the most important question of the script algorithm: what do you do once you realize that you haven't met your digit and punctuation character goals?

Failed Password Generation: Now What?

One solution is simply to try again, but if the user sets up an impossible situation, like a six-character password with four digits and four punctuation characters, or even four and two, that's no bueno.

Another possibility is to step through the generated password, replacing unconstrained values (such as upper and lowercase) with the specific value required. This has the consequence that if you ask for a lot of punctuation or digits, you're going to end up having those requested characters front-loaded, which isn't exactly random.

So, Let's Rethink the Problem

What if, instead of producing a random password, you split it into two steps? The first step is to generate the required number of random digits and random punctuation characters, add completely random values to add up to the desired length, then "shuffle" the result to produce the final password.

I know, you're almost done with this program, but that's a really interesting solution that sidesteps a lot of problems, so let's just retrench and start over!

Actually, it's not that bad, because most of the work's already been done. This will just make it simpler:


while [ ${#password} -lt $length ] ; do
   if [ $digitsAdded -lt $digitGoal ] ; then
     letter=${digits:$(( $RANDOM % ${#digits} )):1}
     digitsAdded=$(( $digitsAdded +1 ))
   elif [ $punctAdded -lt $punctGoal ] ; then
     letter=${punct:$(( $RANDOM % ${#punct} )):1}
     punctAdded=$(( $punctAdded +1 ))
   else
     case $(( $RANDOM % 7 )) in
       0|1) letter=${uppers:$(($RANDOM % ${#uppers})):1} ;;
       2|3) letter=${lowers:$(($RANDOM % ${#lowers})):1} ;;
       4|5) letter=${punct:$(($RANDOM % ${#punct} )):1}
             punctAdded=$(( $punctAdded + 1 ))        ;;
       6) letter=${digits:$(( $RANDOM % ${#digits} )):1}
             digitsAdded=$(( $digitsAdded +1 ))       ;;
     esac
   fi
   password="${password}$letter"
done

Without the final password-scrambler code, here's what you get with a couple invocations for a 15-character password with at least four digits and at least four punctuation characters:


$ sh makepw.sh -l 15 -p 4 -d 4
Interim password generated: 8119?:)@_g&rw%=
$ sh makepw.sh -l 15 -p 4 -d 4
Interim password generated: 7599}(|&l*4KFY/

You clearly can see how these are front-loaded, with the digits required, the punctuation required and then "everything else".

There are a lot of ways to shuffle the letters in a word within a shell script, ranging from invoking Perl or Awk to using the Linux shuf command to solving it yourself. I'm going to leave this as an exercise for the reader, because with that small added step, you've got a fully functional password generator that's ready to take on your hundreds of system users.

About the Author

Dave Taylor has been hacking shell scripts on UNIX and Linux systems for a really long time. He's the author of Learning Unix for Mac OS X and Wicked Cool Shell Scripts. You can find him on Twitter as @DaveTaylor, and you can reach him through his tech Q&A site: Ask Dave Taylor.

Dave Taylor