Track 10 · Lesson 3 of 6

Build a hash and checksum tool

Build hashtool.sh on your phone, one small step at a time: hash a file or standard input, choose the algorithm with --algo, save the digests with --write, and check a checksum file with --check. The whole script is at the end.


In this lesson you build hashtool.sh, a checksum tool for your phone. You start with a single sha256sum command and grow it, one small step at a time, until it can hash a file or standard input, choose its algorithm with --algo, save the digests with --write, and check a checksum file with --check. Each step shows the whole file as it stands and the real output it prints, so you can run it after every step. The finished program sits in The whole script at the end: copy that one block into a file and you have the working tool. If you would rather use the tool than build it, the hash and checksum tool does the same job in the browser. Everything here runs offline in Termux as an ordinary app, with no root.

What you'll be able to do

  • Hash one file and read the digest it prints.
  • Put the work in a script that takes a filename argument.
  • Choose SHA-256, SHA-1, MD5 or openssl with a --algo option.
  • Hash several files in one run and print one line per file.
  • Write a checksum file with --write and check it later with --check.
  • Read standard input when no file is given.
  • Explain why a digest from the same place as the file proves nothing, and what a signature adds.

Step 1: Hash one file with sha256sum

Make two small files to work with. printf writes exactly the text you give it, so the digests below will match yours. Then hash the first file with sha256sum, which is already on the phone.

printf 'alpha\n' > a.txt
printf 'bravo\n' > b.txt
sha256sum a.txt
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt

The line has two parts. First the digest: sixty-four hexadecimal characters, always that length no matter how large the file is. Then two spaces and the filename. The digest is a function of the file contents and nothing else, so hash the same file again and you get the same sixty-four characters, and change one byte and every one of them changes.

Step 2: Wrap it in a script that takes a filename

Typing sha256sum each time is fine until you want your own defaults, so put the work in a file. Create hashtool.sh with a shebang line and one command that hashes the file named as the first argument.

#!/bin/sh
sha256sum "$1"

Run it by naming the file after the script.

sh hashtool.sh a.txt
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt

$1 is the first argument. Keep the quotes around it: "$1" passes the name as one word, so a filename with a space stays a single name. The #!/bin/sh line tells the system to run the file with sh, which is the shell every phone has.

Step 3: Choose the algorithm with --algo

Now let the tool choose its algorithm. Add a loop that reads the arguments: --algo takes the next word as the algorithm name, and anything else is kept as the file. A case then maps the name to the command that does the work.

#!/bin/sh
algo=sha256
file=

while [ $# -gt 0 ]; do
  case "$1" in
    --algo) algo=$2; shift 2 ;;
    *)      file=$1; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum ;;
  openssl) hash_cmd="openssl dgst -sha256" ;;
  *)
    echo "hashtool: unknown algorithm: $algo" >&2
    exit 2 ;;
esac

$hash_cmd "$file"

The default is sha256, so a bare filename still works, and --algo switches tools.

sh hashtool.sh a.txt
sh hashtool.sh --algo md5 a.txt
sh hashtool.sh --algo openssl a.txt
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt
9f9f90dbe3e5ee1218c86b8839db1995  a.txt
SHA2-256(a.txt)= b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060

The third line shows the same SHA-256 digest as the first, but written differently: openssl dgst prints a label and an equals sign before the digest, so its line is not yet in the two-space shape the sum tools use. You even that out in the next step. An unknown name falls through to the case default and stops with a clear message rather than a shell error. On MD5 and SHA-1: they are quick and fine for spotting accidental corruption, but practical collisions have been built for both, so do not use them against an attacker who might choose the file for you.

Step 4: Hash several files in one run

Hash as many files as you like at once. Collect every name into files, then loop over them. The new hash_line function prints one line per file, and it rewrites the openssl output into the same two-space shape as the sum tools, so every line looks alike.

#!/bin/sh
algo=sha256
files=

while [ $# -gt 0 ]; do
  case "$1" in
    --algo) algo=$2; shift 2 ;;
    *)      files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum ;;
  openssl) hash_cmd="openssl dgst -sha256" ;;
  *) echo "hashtool: unknown algorithm: $algo" >&2; exit 2 ;;
esac

hash_line() {
  if [ "$algo" = openssl ]; then
    digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

for f in $files; do
  hash_line "$f"
done
sh hashtool.sh a.txt b.txt
sh hashtool.sh --algo openssl a.txt
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt
5da8f23decf397b13f4f55b6fb8a61936238bfe08ed9d901132974f1beccc45c  b.txt
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt

Two spaces between the digest and the name is the format the check mode reads, so making every line uniform now pays off in Step 6. The openssl line, which used to carry a label, now looks exactly like the sum tool lines.

Step 5: Save the digests with --write

Add --write to save the digests instead of printing them. When write_to is set, redirect standard output to that file with exec >, and everything printed after that lands in the file.

#!/bin/sh
algo=sha256
write_to=
files=

while [ $# -gt 0 ]; do
  case "$1" in
    --algo)  algo=$2; shift 2 ;;
    --write) write_to=$2; shift 2 ;;
    *)       files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum ;;
  openssl) hash_cmd="openssl dgst -sha256" ;;
  *) echo "hashtool: unknown algorithm: $algo" >&2; exit 2 ;;
esac

hash_line() {
  if [ "$algo" = openssl ]; then
    digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

if [ -n "$write_to" ]; then
  exec > "$write_to"
fi

for f in $files; do
  hash_line "$f"
done
sh hashtool.sh --write SHA256SUMS a.txt b.txt
cat SHA256SUMS
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt
5da8f23decf397b13f4f55b6fb8a61936238bfe08ed9d901132974f1beccc45c  b.txt

The first command prints nothing, because its output went into the file; cat then shows what was saved. SHA256SUMS is the name projects often use for this file. It is plain text: a digest, two spaces, a filename, one line per file. Keep it somewhere safe, because it is your record of what those files were when you last trusted them.

Step 6: Check a checksum file with --check

Add --check to verify a checksum file. Each sum tool has a -c flag that reads the file, hashes the files it names, and compares the results. openssl has no such flag, so the check uses the sum tool for the same digest, which is why the case now sets sum_cmd alongside hash_cmd.

#!/bin/sh
algo=sha256
write_to=
check_file=
files=

while [ $# -gt 0 ]; do
  case "$1" in
    --algo)  algo=$2; shift 2 ;;
    --write) write_to=$2; shift 2 ;;
    --check) check_file=$2; shift 2 ;;
    *)       files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum; sum_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum;   sum_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum;    sum_cmd=md5sum ;;
  openssl) hash_cmd="openssl dgst -sha256"; sum_cmd=sha256sum ;;
  *) echo "hashtool: unknown algorithm: $algo" >&2; exit 2 ;;
esac

hash_line() {
  if [ "$algo" = openssl ]; then
    digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

if [ -n "$check_file" ]; then
  $sum_cmd -c "$check_file"
  exit $?
fi

if [ -n "$write_to" ]; then
  exec > "$write_to"
fi

for f in $files; do
  hash_line "$f"
done
sh hashtool.sh --check SHA256SUMS
a.txt: OK
b.txt: OK

Every line ends in OK when the file still matches what you recorded. Change one file and check again, and that line reads FAILED while the others stay OK. The command also exits with status 1 when any line fails, so a script can test $? afterwards and act on it.

OK means unchanged, not trustworthy

OK says only that the file is byte for byte what it was when the line was written. It says nothing about who wrote the file. If the file and the checksum both came from the same place, then whoever changed one could just as easily change the other to match, and the check would still print OK. A signature, made with the publisher's private key, is what ties a file to an author. A checksum on its own cannot.

Step 7: Read standard input when no file is given

When you give no filename, read from standard input, so the tool drops into a pipeline. The sum tools label that input with a single dash.

#!/bin/sh
algo=sha256
write_to=
check_file=
files=

while [ $# -gt 0 ]; do
  case "$1" in
    --algo)  algo=$2; shift 2 ;;
    --write) write_to=$2; shift 2 ;;
    --check) check_file=$2; shift 2 ;;
    *)       files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum; sum_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum;   sum_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum;    sum_cmd=md5sum ;;
  openssl) hash_cmd="openssl dgst -sha256"; sum_cmd=sha256sum ;;
  *) echo "hashtool: unknown algorithm: $algo" >&2; exit 2 ;;
esac

hash_line() {
  if [ "$algo" = openssl ]; then
    digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

if [ -n "$check_file" ]; then
  $sum_cmd -c "$check_file"
  exit $?
fi

if [ -n "$write_to" ]; then
  exec > "$write_to"
fi

if [ -z "$files" ]; then
  hash_line -
else
  for f in $files; do
    hash_line "$f"
  done
fi
printf 'hello\n' | sh hashtool.sh
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  -

The - stands for standard input. That one line hashes those six bytes with no file in sight, which is what you want when the data is arriving from another command.

Step 8: A usage message and error handling

Finish the tool with a usage message and honest errors. The loop now checks that --algo, --write and --check each carry a value, rejects an unknown option, and the hash loop checks that every named file exists. -h prints the usage text, and -- stops option reading so a file whose name begins with a dash can still be hashed.

#!/bin/sh
# hashtool.sh - hash files or standard input, write a checksum file, or check one.

usage() {
  cat <<'EOF'
hashtool.sh - hash files or standard input, and check checksums

Usage:
  hashtool.sh [options] [FILE]

With no FILE, reads standard input.

Options:
  -a, --algo NAME    sha256 (default), sha1, md5, or openssl
  -w, --write FILE   write the checksums to FILE instead of the screen
  -c, --check FILE   check the digests listed in FILE
  -h, --help         show this message

Examples:
  hashtool.sh a.txt
  hashtool.sh --algo md5 a.txt
  hashtool.sh --write SHA256SUMS a.txt b.txt
  hashtool.sh --check SHA256SUMS
  printf 'hello\n' | hashtool.sh
EOF
}

algo=sha256
write_to=
check_file=
files=

while [ $# -gt 0 ]; do
  case "$1" in
    -a|--algo)
      if [ $# -lt 2 ]; then echo "hashtool: --algo needs a value" >&2; exit 2; fi
      algo=$2; shift 2 ;;
    -w|--write)
      if [ $# -lt 2 ]; then echo "hashtool: --write needs a value" >&2; exit 2; fi
      write_to=$2; shift 2 ;;
    -c|--check)
      if [ $# -lt 2 ]; then echo "hashtool: --check needs a value" >&2; exit 2; fi
      check_file=$2; shift 2 ;;
    -h|--help)
      usage; exit 0 ;;
    --)
      shift; break ;;
    -*)
      echo "hashtool: unknown option: $1" >&2
      echo "try: hashtool.sh --help" >&2
      exit 2 ;;
    *)
      files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum; sum_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum;   sum_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum;    sum_cmd=md5sum ;;
  openssl) hash_cmd=;          sum_cmd=sha256sum ;;
  *)
    echo "hashtool: unknown algorithm: $algo" >&2
    echo "use one of: sha256, sha1, md5, openssl" >&2
    exit 2 ;;
esac

# One digest line, in the two-space format the check mode expects.
# $1 is a path, or "-" for standard input.
hash_line() {
  if [ "$algo" = openssl ]; then
    if [ "$1" = "-" ]; then
      digest=$(openssl dgst -sha256 | sed 's/^.*= //')
    else
      digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    fi
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

# Check mode: let the matching sum tool read the file and print OK or FAILED.
if [ -n "$check_file" ]; then
  if [ ! -f "$check_file" ]; then
    echo "hashtool: no such checksum file: $check_file" >&2
    exit 2
  fi
  $sum_cmd -c "$check_file"
  exit $?
fi

# Write mode: send everything that follows to the checksum file.
if [ -n "$write_to" ]; then
  if ! ( : > "$write_to" ) 2>/dev/null; then
    echo "hashtool: cannot write to $write_to" >&2
    exit 2
  fi
  exec > "$write_to"
fi

# Hash mode: the listed files, or standard input when none are given.
if [ -z "$files" ]; then
  hash_line -
else
  for f in $files; do
    if [ ! -f "$f" ]; then
      echo "hashtool: no such file: $f" >&2
      exit 2
    fi
    hash_line "$f"
  done
fi
sh hashtool.sh --help
sh hashtool.sh --bogus
sh hashtool.sh nope.txt
sh hashtool.sh --algo sha999 a.txt
hashtool.sh - hash files or standard input, and check checksums

Usage:
  hashtool.sh [options] [FILE]

With no FILE, reads standard input.

Options:
  -a, --algo NAME    sha256 (default), sha1, md5, or openssl
  -w, --write FILE   write the checksums to FILE instead of the screen
  -c, --check FILE   check the digests listed in FILE
  -h, --help         show this message

Examples:
  hashtool.sh a.txt
  hashtool.sh --algo md5 a.txt
  hashtool.sh --write SHA256SUMS a.txt b.txt
  hashtool.sh --check SHA256SUMS
  printf 'hello\n' | hashtool.sh
hashtool: unknown option: --bogus
try: hashtool.sh --help
hashtool: no such file: nope.txt
hashtool: unknown algorithm: sha999
use one of: sha256, sha1, md5, openssl

A bad option, a missing value or a missing file prints one short line to standard error and exits with status 2, never a shell error or a stack trace. The usage text names every option, so anyone who finds the script can work out what it does.

The whole script

Here is the finished file in one piece. Save it as hashtool.sh, or copy this block into a file of that name, and it runs exactly as it stands.

#!/bin/sh
# hashtool.sh - hash files or standard input, write a checksum file, or check one.

usage() {
  cat <<'EOF'
hashtool.sh - hash files or standard input, and check checksums

Usage:
  hashtool.sh [options] [FILE]

With no FILE, reads standard input.

Options:
  -a, --algo NAME    sha256 (default), sha1, md5, or openssl
  -w, --write FILE   write the checksums to FILE instead of the screen
  -c, --check FILE   check the digests listed in FILE
  -h, --help         show this message

Examples:
  hashtool.sh a.txt
  hashtool.sh --algo md5 a.txt
  hashtool.sh --write SHA256SUMS a.txt b.txt
  hashtool.sh --check SHA256SUMS
  printf 'hello\n' | hashtool.sh
EOF
}

algo=sha256
write_to=
check_file=
files=

while [ $# -gt 0 ]; do
  case "$1" in
    -a|--algo)
      if [ $# -lt 2 ]; then echo "hashtool: --algo needs a value" >&2; exit 2; fi
      algo=$2; shift 2 ;;
    -w|--write)
      if [ $# -lt 2 ]; then echo "hashtool: --write needs a value" >&2; exit 2; fi
      write_to=$2; shift 2 ;;
    -c|--check)
      if [ $# -lt 2 ]; then echo "hashtool: --check needs a value" >&2; exit 2; fi
      check_file=$2; shift 2 ;;
    -h|--help)
      usage; exit 0 ;;
    --)
      shift; break ;;
    -*)
      echo "hashtool: unknown option: $1" >&2
      echo "try: hashtool.sh --help" >&2
      exit 2 ;;
    *)
      files="$files $1"; shift ;;
  esac
done

case "$algo" in
  sha256)  hash_cmd=sha256sum; sum_cmd=sha256sum ;;
  sha1)    hash_cmd=sha1sum;   sum_cmd=sha1sum ;;
  md5)     hash_cmd=md5sum;    sum_cmd=md5sum ;;
  openssl) hash_cmd=;          sum_cmd=sha256sum ;;
  *)
    echo "hashtool: unknown algorithm: $algo" >&2
    echo "use one of: sha256, sha1, md5, openssl" >&2
    exit 2 ;;
esac

# One digest line, in the two-space format the check mode expects.
# $1 is a path, or "-" for standard input.
hash_line() {
  if [ "$algo" = openssl ]; then
    if [ "$1" = "-" ]; then
      digest=$(openssl dgst -sha256 | sed 's/^.*= //')
    else
      digest=$(openssl dgst -sha256 "$1" | sed 's/^.*= //')
    fi
    printf '%s  %s\n' "$digest" "$1"
  else
    $hash_cmd "$1"
  fi
}

# Check mode: let the matching sum tool read the file and print OK or FAILED.
if [ -n "$check_file" ]; then
  if [ ! -f "$check_file" ]; then
    echo "hashtool: no such checksum file: $check_file" >&2
    exit 2
  fi
  $sum_cmd -c "$check_file"
  exit $?
fi

# Write mode: send everything that follows to the checksum file.
if [ -n "$write_to" ]; then
  if ! ( : > "$write_to" ) 2>/dev/null; then
    echo "hashtool: cannot write to $write_to" >&2
    exit 2
  fi
  exec > "$write_to"
fi

# Hash mode: the listed files, or standard input when none are given.
if [ -z "$files" ]; then
  hash_line -
else
  for f in $files; do
    if [ ! -f "$f" ]; then
      echo "hashtool: no such file: $f" >&2
      exit 2
    fi
    hash_line "$f"
  done
fi

Using it

Hash a single file, choose an algorithm, and read from a pipe.

sh hashtool.sh a.txt
sh hashtool.sh --algo sha1 b.txt
printf 'hello\n' | sh hashtool.sh
b6a98d9ce9a2d9149288fa3df42d377c3e42737afdcdaf714e33c0a100b51060  a.txt
bb596efe9e3023a502013767a0559a94a5eea4bc  b.txt
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  -

Record the digests, check them while the files are untouched, then edit one and check again.

sh hashtool.sh --write SHA256SUMS a.txt b.txt
sh hashtool.sh --check SHA256SUMS
printf 'ALPHA\n' > a.txt
sh hashtool.sh --check SHA256SUMS
a.txt: OK
b.txt: OK
a.txt: FAILED
b.txt: OK

The first check reports both files unchanged. After a.txt is rewritten, the second check marks it FAILED and leaves b.txt as OK. One capital letter was enough to change every character of the digest.

Quick reference

CommandWhat it does
sh hashtool.sh FILEHashes a file with SHA-256
sh hashtool.sh --algo md5 FILEHashes with MD5 instead
sh hashtool.sh --algo openssl FILEHashes with openssl dgst
sh hashtool.sh A BHashes several files, one line each
sh hashtool.sh --write SHA256SUMS A BSaves the digests to a checksum file
sh hashtool.sh --check SHA256SUMSChecks each file and prints OK or FAILED
printf 'text\n' | sh hashtool.shHashes standard input
sh hashtool.sh --helpPrints the usage message

Next steps

Test yourself on this

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

Start