···11+#!/bin/sh
22+#
33+# pa - simple password manager based on age
44+55+pw_add() {
66+ name=$1
77+88+ if yn "Generate a password?"; then
99+ # Generate a password by reading '/dev/urandom' with the
1010+ # 'tr' command to translate the random bytes into a
1111+ # configurable character set.
1212+ #
1313+ # The 'dd' command is then used to read only the desired
1414+ # password length.
1515+ #
1616+ # Regarding usage of '/dev/urandom' instead of '/dev/random'.
1717+ # See: https://www.2uo.de/myths-about-urandom
1818+ pass=$(LC_ALL=C tr -dc "${PA_PATTERN:-_A-Z-a-z-0-9}" < /dev/urandom |
1919+ dd ibs=1 obs=1 count="${PA_LENGTH:-50}" 2>/dev/null)
2020+2121+ else
2222+ # 'sread()' is a simple wrapper function around 'read'
2323+ # to prevent user input from being printed to the terminal.
2424+ sread pass "Enter password"
2525+ sread pass2 "Enter password (again)"
2626+2727+ # Disable this check as we dynamically populate the two
2828+ # passwords using the 'sread()' function.
2929+ # shellcheck disable=2154
3030+ [ "$pass" = "$pass2" ] || die "Passwords do not match"
3131+ fi
3232+3333+ [ "$pass" ] || die "Failed to generate a password"
3434+3535+ # Mimic the use of an array for storing arguments by... using
3636+ # the function's argument list. This is very apt isn't it?
3737+ set -- -c
3838+3939+ # Use 'age' to store the password in an encrypted file.
4040+ # A heredoc is used here instead of a 'printf' to avoid
4141+ # leaking the password through the '/proc' filesystem.
4242+ #
4343+ # Heredocs are sometimes implemented via temporary files,
4444+ # however this is typically done using 'mkstemp()' which
4545+ # is more secure than a leak in '/proc'.
4646+ pubkey=$(sed -n 's/.*\(age\)/\1/p' ~/.age/key.txt)
4747+ age -r "$pubkey" -o "$name.age" <<-EOF &&
4848+ $pass
4949+ EOF
5050+ printf '%s\n' "Saved '$name' to the store."
5151+}
5252+5353+pw_del() {
5454+ yn "Delete pass file '$1'?" && {
5555+ rm -f "$1.age"
5656+5757+ # Remove empty parent directories of a password
5858+ # entry. It's fine if this fails as it means that
5959+ # another entry also lives in the same directory.
6060+ rmdir -p "${1%/*}" 2>/dev/null || :
6161+ }
6262+}
6363+6464+pw_show() {
6565+ age -i ~/.age/key.txt --decrypt "$1.age"
6666+}
6767+6868+pw_list() {
6969+ find . -type f -name \*.age | sed 's/..//;s/\.age$//'
7070+}
7171+7272+pw_gen() {
7373+ if yn "$HOME/.age/key.txt not detected, generate a new one?"; then
7474+ mkdir -p ~/.age
7575+ age-keygen -o ~/.age/key.txt
7676+ fi
7777+}
7878+7979+yn() {
8080+ printf '%s [y/n]: ' "$1"
8181+8282+ # Enable raw input to allow for a single byte to be read from
8383+ # stdin without needing to wait for the user to press Return.
8484+ stty -icanon
8585+8686+ # Read a single byte from stdin using 'dd'. POSIX 'read' has
8787+ # no support for single/'N' byte based input from the user.
8888+ answer=$(dd ibs=1 count=1 2>/dev/null)
8989+9090+ # Disable raw input, leaving the terminal how we *should*
9191+ # have found it.
9292+ stty icanon
9393+9494+ printf '\n'
9595+9696+ # Handle the answer here directly, enabling this function's
9797+ # return status to be used in place of checking for '[yY]'
9898+ # throughout this program.
9999+ glob "$answer" '[yY]'
100100+}
101101+102102+sread() {
103103+ printf '%s: ' "$2"
104104+105105+ # Disable terminal printing while the user inputs their
106106+ # password. POSIX 'read' has no '-s' flag which would
107107+ # effectively do the same thing.
108108+ stty -echo
109109+ read -r "$1"
110110+ stty echo
111111+112112+ printf '\n'
113113+}
114114+115115+glob() {
116116+ # This is a simple wrapper around a case statement to allow
117117+ # for simple string comparisons against globs.
118118+ #
119119+ # Example: if glob "Hello World" '* World'; then
120120+ #
121121+ # Disable this warning as it is the intended behavior.
122122+ # shellcheck disable=2254
123123+ case $1 in $2) return 0; esac; return 1
124124+}
125125+126126+die() {
127127+ printf 'error: %s.\n' "$1" >&2
128128+ exit 1
129129+}
130130+131131+usage() { printf %s "\
132132+pa 0.1.0 - age-based password manager
133133+=> [a]dd [name] - Create a new password, randomly generated
134134+=> [d]el [name] - Delete a password entry.
135135+=> [l]ist - List all entries.
136136+=> [s]how [name] - Show password for an entry.
137137+Password length: export PA_LENGTH=50
138138+Password pattern: export PA_PATTERN=_A-Z-a-z-0-9
139139+Store location: export PA_DIR=~/.local/share/pa
140140+"
141141+exit 0
142142+}
143143+144144+main() {
145145+ : "${PA_DIR:=${XDG_DATA_HOME:=$HOME/.local/share}/pa}"
146146+147147+ command -v age >/dev/null 2>&1 ||
148148+ die "age not found, install per https://github.com/FiloSottile/age"
149149+150150+ command -v age-keygen >/dev/null 2>&1 ||
151151+ die "age-keygen not found, install per https://github.com/FiloSottile/age"
152152+153153+ mkdir -p "$PA_DIR" ||
154154+ die "Couldn't create password directory"
155155+156156+ cd "$PA_DIR" ||
157157+ die "Can't access password directory"
158158+159159+ [ -f ~/.age/key.txt ] || pw_gen
160160+161161+ glob "$1" '[acds]*' && [ -z "$2" ] &&
162162+ die "Missing [name] argument"
163163+164164+ glob "$1" '[cds]*' && [ ! -f "$2.age" ] &&
165165+ die "Pass file '$2' doesn't exist"
166166+167167+ glob "$1" 'a*' && [ -f "$2.age" ] &&
168168+ die "Pass file '$2' already exists"
169169+170170+ glob "$2" '*/*' && glob "$2" '*../*' &&
171171+ die "Category went out of bounds"
172172+173173+ glob "$2" '/*' &&
174174+ die "Category can't start with '/'"
175175+176176+ glob "$2" '*/*' && { mkdir -p "${2%/*}" ||
177177+ die "Couldn't create category '${2%/*}'"; }
178178+179179+ # Restrict permissions of any new files to
180180+ # only the current user.
181181+ umask 077
182182+183183+ # Ensure that we leave the terminal in a usable
184184+ # state on exit or Ctrl+C.
185185+ [ -t 1 ] && trap 'stty echo icanon' INT EXIT
186186+187187+ case $1 in
188188+ a*) pw_add "$2" ;;
189189+ d*) pw_del "$2" ;;
190190+ s*) pw_show "$2" ;;
191191+ l*) pw_list ;;
192192+ *) usage
193193+ esac
194194+}
195195+196196+# Ensure that debug mode is never enabled to
197197+# prevent the password from leaking.
198198+set +x
199199+200200+# Ensure that globbing is globally disabled
201201+# to avoid insecurities with word-splitting.
202202+set -f
203203+204204+[ "$1" ] || usage && main "$@"