Track 10 · Lesson 1 of 6

Build a password generator

Build genpass.sh, a password and passphrase generator for Termux, one small step at a time with openssl, and see the entropy of every secret it makes.


You will build genpass.sh, a password and passphrase generator that runs at the Termux prompt with nothing but the shell and openssl. It takes a length, chooses which kinds of character to draw from, can leave out the ones that look alike, and in passphrase mode builds a few words into one secret that is easier to remember. Every time it prints a secret it also prints how many bits of entropy that secret carries, so you can see the strength instead of guessing it. The script is grown a few lines at a time, and the finished file sits in one block at the end, ready to copy and run.

The same generator also lives as a page you can click, at the password generator. Everything below builds it by hand.

What you'll be able to do

  • Explain why $RANDOM is not a source of secrets, and what is subtly wrong with the usual /dev/urandom pipeline.
  • Read random bytes with openssl rand and turn them into characters from an alphabet you choose.
  • Draw an index without bias, by throwing away bytes that would favour the low end.
  • Work out the entropy of a password in bits with awk.
  • Parse options with a case loop, and fail with a message a person can act on.
  • Build a passphrase from a small word list, and count its bits as well.

Step 1: Why not $RANDOM, and the smallest script that works

$RANDOM is the first thing most people reach for, because it needs no package at all.

echo $RANDOM
11523

It hands you a whole number from 0 to 32767. That range is only fifteen bits wide, since 2 to the 15 is 32768. Worse, it is seeded, and the same seed always replays the same sequence.

RANDOM=42; echo $RANDOM; RANDOM=42; echo $RANDOM
17772
17772

The two lines are identical. A generator you can reseed, with only fifteen bits of range, is not a secret. You need a source with far more state and no way to run it backwards. The kernel has one, and openssl will read it for you. openssl is a Termux package rather than part of the base install, so if openssl version is not found, run pkg install openssl first.

The smallest useful script is one line that asks openssl for a few random bytes. Put this in a file called genpass.sh:

#!/data/data/com.termux/files/usr/bin/bash
openssl rand -base64 12

Run it with bash genpass.sh:

tUa9T7U5UFfCEhm7

openssl rand 12 writes twelve random bytes, and -base64 writes them as sixteen printable characters. Base64 takes three bytes and gives four characters, so twelve bytes become sixteen characters. That is already a real secret, but you cannot choose its length yet, and base64 is not the kind of alphabet you would want to read off a screen and type.

The idiom you will see elsewhere, and why this lesson avoids it

Many guides write tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20. It usually works, but head closes the pipe the moment it has its twenty bytes, so tr is killed by a signal part way through its work. The result then depends on that signal rather than on the two tools agreeing on a length, and most of what is read from the device is thrown away. A tool that asks the kernel for exactly the bytes it needs never has to be stopped from the far end of a pipe, which is why every step here uses openssl rand.

Step 2: Ask for a length

Twelve bytes hard coded means a fixed sixteen characters. Move the count into a variable and read it from the first argument.

#!/data/data/com.termux/files/usr/bin/bash
length=16
openssl rand -base64 "$length"

Run it:

DKgEJW6oQ1CrRnKlKgiA3Q==

Sixteen bytes become twenty four base64 characters. Because sixteen does not divide by three, base64 pads the end with == to fill the last group. The padding is harmless here, but it is noise, and it also tells you the byte count was not a multiple of three. From the next step the password is built from characters instead, so the padding problem disappears.

Step 3: One character from an alphabet you choose

Base64 is a fine alphabet for a token, but a password you type is usually drawn from letters and digits. To draw one character, read a single random byte and use it to pick a position in the alphabet. Replace the whole script with this:

#!/data/data/com.termux/files/usr/bin/bash
alpha=abcdefghijklmnopqrstuvwxyz
b=$(openssl rand 1 | od -An -tu1)
idx=$(( b % ${#alpha} ))
printf '%s\n' "${alpha:$idx:1}"

Run it a few times:

b

openssl rand 1 writes one byte, and od -An -tu1 turns it into a number from 0 to 255. ${#alpha} is the length of the alphabet, here 26, and ${alpha:$idx:1} takes the character at that position. Taking the byte modulo the alphabet length is the obvious move, and it is almost right. It is slightly biased: 256 is not a multiple of 26, so the first few positions come up a little more often than the rest. Step 4 removes that bias.

Step 4: A whole password, without the bias

The fix is to throw away any byte that falls in the uneven tail and draw again. Only bytes below a limit are used, where the limit is the largest multiple of the alphabet length that still fits in a byte. Then wrap the whole thing in a loop until the password is long enough. Replace the body of the script with this, keeping the shebang line at the top:

length=16
alpha=abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
limit=$(( 256 - (256 % ${#alpha}) ))
out=""
while [ "${#out}" -lt "$length" ]; do
  for b in $(openssl rand 32 | od -An -tu1); do
    if [ "$b" -lt "$limit" ]; then
      out="${out}${alpha:$(( b % ${#alpha} )):1}"
      if [ "${#out}" -ge "$length" ]; then
        break
      fi
    fi
  done
done
printf '%s\n' "$out"

Run it:

x7nbgk25pnYnHe5k

openssl rand 32 now fetches a batch of bytes in one go, and od lists them as numbers. Each number below the limit becomes one character, so thirty two bytes are usually more than enough for sixteen characters, and the loop tops the batch up if it runs short. Every position is now equally likely, and the length is whatever you set in length.

Step 5: Choose the character classes

Hard coding the alphabet means you cannot ask for symbols, or leave out the digits. Take a short list of class letters and build the alphabet from it. Replace the single alpha= line from Step 4 with this block:

classes="${1:-lud}"
lower=abcdefghijklmnopqrstuvwxyz
upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ
digits=0123456789
symbols='!@#$%^&*()-_=+[]{};:,.?/'
alpha=""
i=0
while [ "$i" -lt "${#classes}" ]; do
  c="${classes:$i:1}"
  case "$c" in
    l) alpha="${alpha}${lower}" ;;
    u) alpha="${alpha}${upper}" ;;
    d) alpha="${alpha}${digits}" ;;
    s) alpha="${alpha}${symbols}" ;;
  esac
  i=$(( i + 1 ))
done

Run it with a class list of luds:

bash genpass.sh luds
Kf@W:!_V.92nB(&#

luds means lower case, upper case, digits and symbols, and the alphabet is now eighty six characters long. Run it with lu and you get letters only, drawn from fifty two characters. The default when you pass nothing is lud, letters and digits.

Step 6: Leave out the lookalikes

0 and O, 1 and l and I are easy to confuse when you read a password off one screen and type it into another. A single flag can drop them from the alphabet. Add these two lines just after the classes= line:

skip=0
[ "${2:-}" = "-x" ] && skip=1

Then add this filter just after the done that closes the class loop:

if [ "$skip" -eq 1 ]; then
  alpha=$(printf '%s' "$alpha" | tr -d '0O1Il|o')
fi

Run it with the flag:

bash genpass.sh lud -x
hLRBSvRAWxmEadn8

tr -d deletes the named characters from the alphabet, so the pool drops from sixty two to fifty six and none of the confusing ones can appear. The length is unchanged, only the pool it draws from is smaller, which is why the entropy falls slightly too.

Step 7: Add the entropy

A password's strength is the size of the space an attacker has to search. Each character multiplies the possibilities by the length of the alphabet, and a log base 2 turns that product into a sum you can read: the bits are the length times the log base 2 of the alphabet size. awk has a natural logarithm, so divide by log(2). Replace the final printf line with these two:

bits=$(awk -v c="$length" -v n="${#alpha}" 'BEGIN { printf "%.2f", c * log(n) / log(2) }')
printf '%s  [%d chars, alphabet %d, %s bits]\n' "$out" "$length" "${#alpha}" "$bits"

Run it:

bash genpass.sh luds
E{5z:0F:7QLw+$nj  [16 chars, alphabet 86, 102.82 bits]

Sixteen characters from an eighty six character alphabet is about 103 bits. The log base 2 of 86 is about 6.43, and sixteen of those make 102.82. The tool now tells you the strength rather than leaving you to guess it. A shorter alphabet lowers the figure, and a longer password raises it, and you can see both happen as you change the options.

Step 8: Real argument parsing

Reading the class list from $1 and the flag from $2 is fragile: the order matters, and a third option would have nowhere to go. Replace the two positional lines, classes="${1:-lud}" and the skip pair, with a loop over $1 and a case. Add the settings and the loop at the top of the script, just after the shebang:

set -u

length=16
classes=lud
skip_lookalike=0

die() {
  printf 'genpass: %s\n' "$1" >&2
  exit 1
}

while [ $# -gt 0 ]; do
  case "$1" in
    -l)
      [ $# -ge 2 ] || die "option -l needs a length"
      length="$2"
      shift 2
      ;;
    -c)
      [ $# -ge 2 ] || die "option -c needs a list of character classes"
      classes="$2"
      shift 2
      ;;
    -x)
      skip_lookalike=1
      shift
      ;;
    *)
      die "unexpected argument '$1'"
      ;;
  esac
done

Run it with options in any order:

bash genpass.sh -l 20 -c luds
a:6=xUZ[Imq7}l@T[jXi  [20 chars, alphabet 86, 128.53 bits]

Each option that takes a value consumes it with shift 2, and shift on its own steps past a flag that takes none. set -u makes the script stop if it ever reads a variable that was never set, which turns a typo into a clear error instead of a quiet empty value. The lookalike filter from Step 6 keeps its place, with one word changed: its condition is now if [ "$skip_lookalike" -eq 1 ]; then.

Step 9: Passphrase mode

A long passphrase of ordinary words is easier to remember than a short scramble, and it can carry a fair number of bits. Add a small word list and a -p flag, with -w for the number of words. Add the two defaults, passphrase=0 and words=4, next to the other settings at the top, and add the word list after the symbols= line:

wordlist=(
  alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima
  mike november oscar papa quebec romeo sierra tango uniform victor whiskey
  xray yankee zulu amber basin cedar dusk ember fern glade hollow ivory jasper
  kettle lantern meadow nectar orbit pebble quarry ripple saddle timber umber
  velvet willow xenon yarrow zephyr acorn bramble cinder dune flint granite
  harbour indigo juniper kelp lagoon magnet
)

Add -p and -w to the parsing loop:

    -w)
      [ $# -ge 2 ] || die "option -w needs a word count"
      words="$2"
      shift 2
      ;;
    -p)
      passphrase=1
      shift
      ;;

Then replace the password loop with the shared index helper, the two small builders that use it, and the branch that picks between them:

rand_indices() {
  local count="$1" n="$2"
  local limit=$(( 256 - (256 % n) ))
  local got=0 b
  while [ "$got" -lt "$count" ]; do
    for b in $(openssl rand 32 | od -An -tu1); do
      if [ "$b" -lt "$limit" ]; then
        printf '%s\n' "$(( b % n ))"
        got=$(( got + 1 ))
        if [ "$got" -ge "$count" ]; then
          break
        fi
      fi
    done
  done
}

make_password() {
  local out="" i
  for i in $(rand_indices "$length" "${#alpha}"); do
    out="${out}${alpha:$i:1}"
  done
  printf '%s' "$out"
}

make_passphrase() {
  local out="" i first=1
  for i in $(rand_indices "$words" "${#wordlist[@]}"); do
    if [ "$first" -eq 1 ]; then
      out="${wordlist[$i]}"
      first=0
    else
      out="${out}-${wordlist[$i]}"
    fi
  done
  printf '%s' "$out"
}

if [ "$passphrase" -eq 1 ]; then
  secret=$(make_passphrase)
  unit="words"
  size_label="list"
  size=${#wordlist[@]}
  count="$words"
else
  secret=$(make_password)
  unit="chars"
  size_label="alphabet"
  size=${#alpha}
  count="$length"
fi

bits=$(awk -v c="$count" -v n="$size" 'BEGIN { printf "%.2f", c * log(n) / log(2) }')
printf '%s  [%s %s, %s %s, %s bits]\n' "$secret" "$count" "$unit" "$size_label" "$size" "$bits"

Run it:

bash genpass.sh -p -w 5
harbour-amber-india-xenon-sierra  [5 words, list 64, 30.00 bits]

Both branches now go through the same rand_indices helper, so the fairness argument is made once and reused. Be honest about the arithmetic here: a list of 64 words gives 6 bits for each choice, so five words is only 30 bits. That is easy to remember but weak as a secret. A real word list, such as the 7776 word list the EFF publishes, gives about 12.9 bits a word, so six of those words is nearer 77 bits. Length in words, and the size of the list, are the two levers.

Step 10: A usage message and clean errors

The last piece is the part that makes a script pleasant to use: a help message, and a clear complaint when something is wrong rather than a stack trace. Add the usage function near the top:

usage() {
  cat <<'EOF'
genpass.sh - make a password or a passphrase, and report its entropy.

Usage: genpass.sh [options]

  -l N        length of the password in characters (default 16)
  -c CLASSES  which characters to draw from, any of:
                l  lower case letters
                u  upper case letters
                d  digits
                s  symbols
              put several together, for example -c luds (default lud)
  -x          leave out lookalike characters: 0 O 1 I l o
  -p          passphrase mode: a few words joined by hyphens
  -w N        how many words in the passphrase (default 4)
  -h          print this help and exit

Examples:
  genpass.sh
  genpass.sh -l 24 -c luds
  genpass.sh -l 20 -x
  genpass.sh -p -w 5
EOF
}

Add -h and the two catch alls to the parsing loop:

    -h|--help)
      usage
      exit 0
      ;;
    -*)
      die "unknown option '$1' (try -h)"
      ;;
    *)
      die "unexpected argument '$1' (try -h)"
      ;;

Then add the checks just after the loop, before the alphabet is built:

case "$length" in
  ''|*[!0-9]*) die "length must be a whole number, got '$length'" ;;
esac
[ "$length" -ge 1 ] || die "length must be at least 1"

case "$words" in
  ''|*[!0-9]*) die "word count must be a whole number, got '$words'" ;;
esac
[ "$words" -ge 1 ] || die "word count must be at least 1"

command -v openssl >/dev/null 2>&1 || die "openssl is not installed; run: pkg install openssl"

And once the class loop has built the alphabet, make sure it is not empty:

[ -n "$alpha" ] || die "no characters selected; pass -c with some of l, u, d, s"

Also add unknown character class to the class loop, so a typo in -c is caught where it happens:

    *) die "unknown character class '$c' (use l, u, d or s)" ;;

Try it with a bad option:

bash genpass.sh -z
genpass: unknown option '-z' (try -h)

And with an argument that is not a number:

bash genpass.sh -l abc
genpass: length must be a whole number, got 'abc'

Both messages go to standard error and the script exits with a non zero status, so it composes properly in a pipeline. The preflight on openssl is the one that saves the most confusion: without it, a missing package shows up much later as a strange error from inside the loop.

The whole script

#!/data/data/com.termux/files/usr/bin/bash
# genpass.sh - make a password or a passphrase, and report its entropy.
#
# Runs in Termux on Android as an ordinary app: no root and no network. The
# only thing it needs that is not in the base install is openssl, so run:
#
#     pkg install openssl
#
# Examples:
#     bash genpass.sh
#     bash genpass.sh -l 24 -c luds
#     bash genpass.sh -l 20 -x
#     bash genpass.sh -p -w 5

set -u

length=16
classes=lud
skip_lookalike=0
passphrase=0
words=4

lower=abcdefghijklmnopqrstuvwxyz
upper=ABCDEFGHIJKLMNOPQRSTUVWXYZ
digits=0123456789
symbols='!@#$%^&*()-_=+[]{};:,.?/'

wordlist=(
  alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima
  mike november oscar papa quebec romeo sierra tango uniform victor whiskey
  xray yankee zulu amber basin cedar dusk ember fern glade hollow ivory jasper
  kettle lantern meadow nectar orbit pebble quarry ripple saddle timber umber
  velvet willow xenon yarrow zephyr acorn bramble cinder dune flint granite
  harbour indigo juniper kelp lagoon magnet
)

usage() {
  cat <<'EOF'
genpass.sh - make a password or a passphrase, and report its entropy.

Usage: genpass.sh [options]

  -l N        length of the password in characters (default 16)
  -c CLASSES  which characters to draw from, any of:
                l  lower case letters
                u  upper case letters
                d  digits
                s  symbols
              put several together, for example -c luds (default lud)
  -x          leave out lookalike characters: 0 O 1 I l o
  -p          passphrase mode: a few words joined by hyphens
  -w N        how many words in the passphrase (default 4)
  -h          print this help and exit

Examples:
  genpass.sh
  genpass.sh -l 24 -c luds
  genpass.sh -l 20 -x
  genpass.sh -p -w 5
EOF
}

die() {
  printf 'genpass: %s\n' "$1" >&2
  exit 1
}

# Print COUNT indices in the range 0..N-1, one per line, each one fair.
# A byte from openssl rand runs 0..255. Any byte at or above LIMIT is thrown
# away and another is drawn, so every index is equally likely. Taking the byte
# modulo N without that step would favour the low indices.
rand_indices() {
  local count="$1" n="$2"
  local limit=$(( 256 - (256 % n) ))
  local got=0 b
  while [ "$got" -lt "$count" ]; do
    for b in $(openssl rand 32 | od -An -tu1); do
      if [ "$b" -lt "$limit" ]; then
        printf '%s\n' "$(( b % n ))"
        got=$(( got + 1 ))
        if [ "$got" -ge "$count" ]; then
          break
        fi
      fi
    done
  done
}

make_password() {
  local out="" i
  for i in $(rand_indices "$length" "${#alpha}"); do
    out="${out}${alpha:$i:1}"
  done
  printf '%s' "$out"
}

make_passphrase() {
  local out="" i first=1
  for i in $(rand_indices "$words" "${#wordlist[@]}"); do
    if [ "$first" -eq 1 ]; then
      out="${wordlist[$i]}"
      first=0
    else
      out="${out}-${wordlist[$i]}"
    fi
  done
  printf '%s' "$out"
}

while [ $# -gt 0 ]; do
  case "$1" in
    -l)
      [ $# -ge 2 ] || die "option -l needs a length"
      length="$2"
      shift 2
      ;;
    -c)
      [ $# -ge 2 ] || die "option -c needs a list of character classes"
      classes="$2"
      shift 2
      ;;
    -w)
      [ $# -ge 2 ] || die "option -w needs a word count"
      words="$2"
      shift 2
      ;;
    -x)
      skip_lookalike=1
      shift
      ;;
    -p)
      passphrase=1
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    -*)
      die "unknown option '$1' (try -h)"
      ;;
    *)
      die "unexpected argument '$1' (try -h)"
      ;;
  esac
done

case "$length" in
  ''|*[!0-9]*) die "length must be a whole number, got '$length'" ;;
esac
[ "$length" -ge 1 ] || die "length must be at least 1"

case "$words" in
  ''|*[!0-9]*) die "word count must be a whole number, got '$words'" ;;
esac
[ "$words" -ge 1 ] || die "word count must be at least 1"

command -v openssl >/dev/null 2>&1 || die "openssl is not installed; run: pkg install openssl"

# Build the alphabet one class at a time, and reject anything that is not a
# known class so a typo is caught here rather than in the output.
alpha=""
i=0
while [ "$i" -lt "${#classes}" ]; do
  c="${classes:$i:1}"
  case "$c" in
    l) alpha="${alpha}${lower}" ;;
    u) alpha="${alpha}${upper}" ;;
    d) alpha="${alpha}${digits}" ;;
    s) alpha="${alpha}${symbols}" ;;
    *) die "unknown character class '$c' (use l, u, d or s)" ;;
  esac
  i=$(( i + 1 ))
done
[ -n "$alpha" ] || die "no characters selected; pass -c with some of l, u, d, s"

if [ "$skip_lookalike" -eq 1 ]; then
  alpha=$(printf '%s' "$alpha" | tr -d '0O1Il|o')
fi

if [ "$passphrase" -eq 1 ]; then
  secret=$(make_passphrase)
  unit="words"
  size_label="list"
  size=${#wordlist[@]}
  count="$words"
else
  secret=$(make_password)
  unit="chars"
  size_label="alphabet"
  size=${#alpha}
  count="$length"
fi

# Entropy in bits is the number of choices times the log base 2 of how many
# values each choice can take. awk has a natural log, so divide by log(2).
bits=$(awk -v c="$count" -v n="$size" 'BEGIN { printf "%.2f", c * log(n) / log(2) }')

printf '%s  [%s %s, %s %s, %s bits]\n' "$secret" "$count" "$unit" "$size_label" "$size" "$bits"

Using it

A plain sixteen character password from letters and digits:

bash genpass.sh
qFiY5H3sZ4XryY6B  [16 chars, alphabet 62, 95.27 bits]

A longer one, with symbols included:

bash genpass.sh -l 24 -c luds
YC)2c:I-9.PfI,v-tRmdE_-O  [24 chars, alphabet 86, 154.23 bits]

Twenty characters with the lookalikes removed:

bash genpass.sh -l 20 -x
z3ZJpgdC7H4UkhtG7Jev  [20 chars, alphabet 56, 116.15 bits]

A five word passphrase:

bash genpass.sh -p -w 5
kettle-orbit-juniper-ember-magnet  [5 words, list 64, 30.00 bits]

The help message, and the two errors from Step 10:

bash genpass.sh -h
genpass.sh - make a password or a passphrase, and report its entropy.

Usage: genpass.sh [options]

  -l N        length of the password in characters (default 16)
  -c CLASSES  which characters to draw from, any of:
                l  lower case letters
                u  upper case letters
                d  digits
                s  symbols
              put several together, for example -c luds (default lud)
  -x          leave out lookalike characters: 0 O 1 I l o
  -p          passphrase mode: a few words joined by hyphens
  -w N        how many words in the passphrase (default 4)
  -h          print this help and exit

Examples:
  genpass.sh
  genpass.sh -l 24 -c luds
  genpass.sh -l 20 -x
  genpass.sh -p -w 5

Because every secret is drawn fresh from the kernel, the values above will not match the ones you see, and that is the point. What will match is the shape of each line and the entropy figure for the options you chose.

Quick reference

CommandWhat it does
echo $RANDOMA seeded shell number from 0 to 32767, only fifteen bits wide
openssl rand -base64 16Sixteen random bytes as twenty four base64 characters
openssl rand 32 | od -An -tu1Thirty two random bytes as numbers from 0 to 255
$(( 256 - (256 % n) ))The largest multiple of n that fits in a byte, the rejection limit
${alpha:$i:1}The character of the alphabet at position i
awk 'BEGIN { print L * log(n)/log(2) }'The entropy in bits for length L and an alphabet of n
tr -d '0O1Il|o'Drop the lookalike characters from an alphabet
command -v opensslCheck whether openssl is installed before relying on it
pkg install opensslInstall openssl, which is not in the Termux base install

Next steps

Test yourself on this

4 questions and 1 task where you run a real command, with the reasoning explained either way.

Start