A Spaced-Repetition Password Trainer

Motivation and inspiration

Remembering secure passwords is hard. That's why password managers were invented. But there are still passwords that one must remember without the aid of a password manager: these include those used to access the password manager (local user login credentials, cellphone passwords, full disk encryption passwords), the password manager database password itself, and critical credentials that one must absolutely know even if their password manager database is unavailable (such as email account passwords, cloud storage passwords, and critical financial passwords).

To make matters worse, several of these passwords (such as full-disk encryption passwords and password manager database passwords) are used in situations where offline attacks1 are likely possible; this necessitates paying special attention to password strength. I thus needed to memorize a strong, many-word passphrase and reliably and I needed to ensure that I had remembered it before I changed the password to my password manager database—it would be very unfortunate to lose access to my passwords by prematurely adopting a password that I could not reliably remember.

To aid in this memorization, I took inspiration from spaced-repetition approaches used to memorize flash cards and the like. These approaches help a user memorize several pieces of information by questioning the user about pieces of information in sequence, with the time interval between successive presentations of any given piece of information being determined by one's success rate in remembering that piece.

Under similar circumstances of manually entering long, secure passwords into SSH sessions, I accidentally (but serendipitously) memorized them. This experience is another reason I adopted this approach for my password-memorization aid.

How it works

I've adopted the spaced-repetition approach by writing a program, pwtrainer_sr, to be run at regular time intervals; at each run, it uses the amount of time elapsed since the last pass and fail to decide whether or not to prompt the user for their password. I put pwtrainer_sr in my .zlogin file so that there is a possible opportunity to test my memory every time I start a new terminal window If the user is to be prompted, another program is started: pwtrainer. This program queries the user for their password, compares it to a hashed-and-salted copy of the password, and gives feedback as to whether the input was correct or incorrect. Then, pwtrainer_sr keeps track of whether the user successfully guessed the password, and prints statistics (which can be suppressed with the -q flag).

I set up pwtrainer to allow the user to type their attempt at recalling their password in plain, visible text; I believe that this is important in ensuring that the user can spot and correct any typos they may have made in entering their password. Once the user submits their guess, the password is obscured. In my opinion, this strikes an acceptable balance between security and usability.

After using pwtrainer_sr as described above (with it set up to run whenever I opened a new shell window), I began to run into a problem: often, I would open a new window and immediately type out a command while zsh was initializing. If pwtrainer_sr did not prompt me for my password, this command would be run as normal; if it did, however, it would interpret my command as an obviously wrong guess at my password. I thus modified pwtrainer_sr to check if any input is available to read immediately before prompting for the password, and to clear that input first.

When to repeat

On the advice of a friend of mine (Tara Weling) who studies neuroscience, pwtrainer_sr uses the following algorithm to decide whether to prompt the user for their password: let \(n\) be the number of successful attempts at recall since the last failed attempt, and let \(t\) be the number of seconds since the last failed attempt. The user will be tested if and only if \(n\le 1\) or \(t\ge 3600\cdot2^{n-2}\). Thus, the interval between queries decreases exponentially as the number of consecutive successes increases, and this exponential lengthening resets when an error is made. When passed the flag -f, pwtrainer_sr queries the user unconditionally.

Security considerations

It is critical that passwords not be stored in plaintext at rest. Ideally, passwords should be hashed and salted such that the password cannot be recovered easily from the hash even though the hash can be used to verify that a string purporting to be the password is, in fact, the password. To provide this security, I opted to use openssl, which is capable of hashing and salting passwords using the algorithms used by the UNIX /etc/passwd file. While not perfect2, this ensures that the password will be adequately secured.

By default, the password displays in plaintext while the password is being entered but is replaced by a line of asterisks equal in length to the password once the Enter key is pressed. Thus, someone who can see your screen while you're entering the password could see the password in plaintext. I think that this is the right behavior for most circumstances: entering your password into the password trainer is never essential, so you could simply not enter your password when in the presence of a malicious observer; your password is also cleansed from your terminal's window and scrollback buffer once you press Enter.

Using it yourself

These scripts require zsh and openssl. To use this code yourself, first input your password to openssl passwd -stdin -6 -salt [SALT], where [SALT] is an arbitrary string. Then replace [HASH] and [SALT] in the command below with the string returned by the openssl command and the salt you decided on earlier. Additionally, give pwtrainer_sr somewhere to store a log file by either creating a logs directory one level below the directory in which you store pwtrainer_sr, or alter the definition of the logfile variable on line 3 to point to a more suitable location. Put pwtrainer and pwtrainer_sr in a directory listed in your $PATH environment variable, so that your shell can find them. Then run pwtrainer_sr regularly or set your computer up to run it regularly. Have a secure way of retrieving the password you wish to memorize close at hand—you're going to need it a lot in the beginning.

Source code

For pwtrainer, a ZSH script

 1#!/usr/bin/env zsh
 2prompt="Enter password: "
 3fill="*"
 4
 5verifypw()
 6{
 7  [[ $(echo $@ | openssl passwd -stdin -6 -salt [SALT]) ==
 8    '[HASH]' ]]
 9
10  exit $?
11}
12
13#echo -n "\n\e[A$prompt\e[s'
14echo -n "$prompt"
15read input
16extent=$#input
17#echo "\e[u\e[0K*\e[1000b"
18echo "\e[A\e[$(( ${#prompt} + 1 ))G\e[0K$fill\e[$(( ${extent} - 1 ))b"
19if (verifypw $input)
20then
21  print "\e[32mCorrect!\e[0m"
22  exit 0
23else
24  print "\e[31mIncorrect!\e[0m"
25  exit 1
26fi

For pwtrainer_sr, a ZSH script

 1#!/usr/bin/env zsh
 2
 3logfile=$(dirname $(realpath $0))/../logs/$(basename $0)
 4touch $logfile
 5
 6time_of_fail=0
 7time_of_pass=0
 8num_pass=0
 9num_fail=0
10num_pass_since_fail=0
11. $logfile
12
13timesince()
14{
15  print $(( $(date +%s) - $1 ))
16}
17
18#if [[ $(( $(date +%s) - $time_of_fail )) -gt 3600
19#  ||  $(( $(date +%s) - $time_of_pass ))  -gt 86400
20#  || $1 == '-f' ]]
21
22# Based on Tara Weling's recommendations regarding spaced repetition.
23if [[ $num_pass_since_fail -le 1
24  || $(timesince $time_of_pass) -gt $(( 3600*2**($num_pass_since_fail-2) )) 
25  || $1 == '-f' ]]
26then
27  should_redo=1
28else
29  should_redo=0
30fi
31
32if [[ $should_redo == 1 ]]
33then
34  while read -k 1 -t 0
35  do
36    read "?Press enter to continue: "
37  done
38  pwtrainer
39  if [[ $? == 0 ]]
40  then
41    time_of_pass=$(date +%s)
42    num_pass=$(( $num_pass + 1 ))
43    num_pass_since_fail=$(( $num_pass_since_fail + 1 ))
44  else
45    time_of_fail=$(date +%s)
46    num_fail=$(( $num_fail + 1 ))
47    num_pass_since_fail=0
48  fi
49else
50  [[ $1 != "-q" ]] && print No repetition needed
51fi
52
53if [[ $should_redo == 1 || $1 != "-q" ]]
54then
55  printf 'Stats:\n'
56  printf '  Last failure: %s (%u seconds ago)\n' \
57    "$(date -d @$time_of_fail)" \
58    $(( $(date +%s) - $time_of_fail ))
59  printf '  Last pass:    %s (%u seconds ago)\n' \
60    "$(date -d @$time_of_pass)" \
61    $(( $(date +%s) - $time_of_pass ))
62  printf '  Fails: %03u    Passes: %03u    Pass rate: %.2f%%\n' \
63    $num_fail $num_pass \
64    $(( 100.0 * $num_pass / ($num_pass+$num_fail) ))
65fi
66
67typeset -p time_of_pass time_of_fail num_pass num_fail num_pass_since_fail \
68  >$logfile

1

To be precise, an offline attack is one where the attacker has unrestricted ability to guess the password, check that their guess is correct, attempt to access the secured data, and access the data if their guess is correct. This is in contrast to an online attack, where an attacker must submit their guesses to another service to access the protected data; that service is then able to rate-limit, delay, or block the attacker as they see fit. Offline attacks are harder to defend against as they give the attacker the same access as the trusted party the same ciphertext and can do anything they want with it, ad infinitum. The best defenses against offline attacks on encrypted data are to use a strong password and a key derivation function that takes as input a password, transforms it using an algorithm that is difficult to parallelize and optimize, and outputs a key of the length required by the encryption algorithm in use.

2

I would have liked openssl's password hashing function to support modern password hashing/key derivation algorithms like argon2id, which are specifically designed to be hard for modern computer hardware to process efficiently.