Having been inspired by the HIBP1 password checker, I set out to write a script with the following goals:

  1. Check for duplicate/re-used passwords
  2. Check the strength of each password
  3. Check passwords against the pwnedpass API

Preface

The full source code for this script can be found in my public scripts repository: scripts/bash/pass-check.sh

It’s worth nothing that I use passwordstore to generate, and manage my passwords. On mobile, this is done using the official OpenKeychain, and Password Store. Passwords are shared across my devices using Git2

Pump Your Brakes

Instead of jumping right into checking all my passwords, in plain-text, against the pwnedpasswords API, it would be best to figure out how to safely transform them to SHA-13. The API supports sending the first 5 characters of a SHA-1 hash, returning a list of all SHA-1s of exposed passwords (with the exposed count) for the user to verify them on their end.

Gathering Passwords

The easiest way to get a comprehensive list (associative array4) of passwords and their pass path was to use find to look for *.gpg files in my .password-store directory:

# Fetches all passwords in $PASSDIR and checks for duplicates (base check)
getpws()
{
    # Loop over the find (newline-in-filename safe)
    while read -r -d '' p; do
        # Remove the root directory, and file extension
        p=$(printf "%s" "$p" | sed "s|^$PASSDIR/||" | sed "s/.gpg//")

        # Collect the trimmed, sha1 passwords
        pwsha=$(pass "$p" | awk 'FNR==1 {printf "%s", $0}' | sha1sum | awk '{printf "%s", toupper($1)}')
        pws["$p"]="$pwsha"
    done < <(find "$PASSDIR" -name "*.gpg" -type f -print0)
}

To note, find with -print0 is used to avoid printing newline characters (unlikely, but good practice), so that we can utilize the null terminator '' within read -d ''. Also, read -r simply prevents backslashes from being treated in a special way (also good practice!)5

It may be worth mentioning, to folks less familiar with awk, that the FNR==1, in this context, simply helps to get rid of any newline oddities from being piped into sha1sum. I discovered incorrect sha1sum outputs without FNR==1 resulting in a useless password check!

Note

IFS= would not have fixed the above newline issue, as the problem stems from the output of pass "$p" and not the filenames.

That takes care of gathering our passwords, but we’ll revisit this again in the next part.

Sharing is not Caring

The most efficient way of checking for duplicates was simply to iterate over the array of passwords gathered, and check against the current one found in the getpws() function’s loop. The names of the duplicate passwords are stored in another associative array for printing later as part of the “report”.

# Checks for duplicate sha1sums of passwords in the associative array
checkdupes()
{
    for i in "${!pws[@]}"; do
        if [[ "$2" == "${pws[$i]}" ]]; then
            pwdupes["$1"]="$i"
        fi
    done
}

That being done, we just incorporate it into the above getpws() loop!

getpws()
{
    while read -r -d '' p; do
        ...
        checkdupes "$p" "$pwsha"
    done < <(find "$PASSDIR" -name "*.gpg" -type f -print0)
}

This accomplishes our first goal of checking duplicate passwords – hooray!

Passwortstärke

The simplest method of password strength checking, with indications as to why it’s weak (i.e. “Exists in attack dictionary”, “Too short”, etc.) was to use cracklib. Sadly, it’s not the most well-documented or fully-fledged application to fully determine password strength though for my purposes it will be good enough (I don’t care to write my own version of this, yet..).

Note

I made this part of the script optional, as not every user would want to install cracklib on their system.

This addition was made in the following order:

  1. First, we need to find the executable and create yet another useful associative array for us to store the outputs (a.k.a. messages):

    CRACKLIB=$(command -v cracklib-check)
    declare -A pwscracklib
    
  2. Then a convenient function to iterate over all found passwords, safely “expose” them, and run the check storing all relevant “outputs”:

    # Run through the global pws associative array and check for suggestions
    checkcracklib()
    {
        for i in "${!pws[@]}"; do
            msg=$(pass "$i" | awk 'FNR==1 {printf "%s", $0}' | $CRACKLIB | sed s/^.*:[\ \\t]*//)
            if [[ ! "$msg" =~ "OK" ]]; then
                pwscracklib["$i"]="$msg"
            fi
        done
     }
    

Done! It’s that easy.

Have you been Pwned

The last, but most important, step was to add the actual check against the pwnedpass API check! This gets a bit fun as we use Shell Parameter Expansion to trim the first five, and everything after the first five, characters of the full SHA-1 string.

We need to get the full SHA-1 hash of each password, to then query the API using only the first 5 characters of the SHA-1 hash! We will get a list of each exposed (“pwned”) password’s SHA-1 hash, and the amount of times they have been leaked as a response. The prefix of the first 5 characters is dropped in this list, thus we check for a match of our password using everything after the first 5 characters of the SHA-1 hash and we’re done!

# Check passwords against the HIBP password API (requires internet)
checkpwnapi()
{
    for i in "${!pws[@]}"; do
        # Check the pwnedpasswords API via hashing
        pwsha="${pws[$i]}"
        url="https://api.pwnedpasswords.com/range/${pwsha:0:5}"
        res=$(curl -s "$url" | grep "${pwsha:5}")
        if [ "$res" ]; then
            pwunsafe["$i"]=$(printf "%s" "$res" | awk -F ':' '{printf "%d", $2}')
        fi
    done
}

That’s it! The left was to add some fun, colorful printfs as part of the final output report. Feel free to look at the source code mentioned in the Preface to see more details on this as it wasn’t worth including in the write-up.


  1. Have I Been Pwned ↩︎

  2. pass Extended Git Example ↩︎

  3. SHA-1 (Secure Hashing Algorithm) ↩︎

  4. Arrays (Bash Reference Manual) ↩︎

  5. man read ↩︎