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
$RANDOMis not a source of secrets, and what is subtly wrong with the usual/dev/urandompipeline. - Read random bytes with
openssl randand 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
caseloop, 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.
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
| Command | What it does |
|---|---|
echo $RANDOM | A seeded shell number from 0 to 32767, only fifteen bits wide |
openssl rand -base64 16 | Sixteen random bytes as twenty four base64 characters |
openssl rand 32 | od -An -tu1 | Thirty 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 openssl | Check whether openssl is installed before relying on it |
pkg install openssl | Install openssl, which is not in the Termux base install |
Next steps
- Encoding and hashes shows how a value is written down, and why that is not the same as hiding it.
- Passwords, keys and secrets puts a generated secret to work in a passphrase and an SSH key.
- Pipes and redirection explains why the
trpipeline in the callout behaves the way it does whenheadstops reading.
Test yourself on this
4 questions and 1 task where you run a real command, with the reasoning explained either way.