main dotfiles / bin / pa
Eric Bower  ·  2026-08-14
  1#!/bin/sh
  2#
  3# pa - a simple password manager
  4
  5pw_add() {
  6    if yn "generate a password?"; then
  7        pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-A-Za-z0-9_-}") ||
  8            die "couldn't generate a password"
  9    else
 10        # 'sread()' is a simple wrapper function around 'read'
 11        # to prevent user input from being printed to the terminal.
 12        sread pass "enter a password"
 13
 14        [ "$pass" ] ||
 15            die "password can't be empty"
 16
 17        sread pass2 "enter a password (again)"
 18
 19        # Disable this check as we dynamically populate the two
 20        # passwords using the 'sread()' function.
 21        # shellcheck disable=2154
 22        [ "$pass" = "$pass2" ] ||
 23            die "passwords don't match"
 24    fi
 25
 26    mkdir -p "$(dirname -- "$name")" ||
 27        die "couldn't create category '$(dirname -- "$name")'"
 28
 29    # Use 'age' to store the password in an encrypted file.
 30    # A heredoc is used here instead of a 'printf' to avoid
 31    # leaking the password through the '/proc' filesystem.
 32    #
 33    # Heredocs are sometimes implemented via temporary files,
 34    # however this is typically done using 'mkstemp()' which
 35    # is more secure than a leak in '/proc'.
 36    $age --encrypt -R "$recipients_file" -o "./$name.age" <<-EOF ||
 37		$pass
 38	EOF
 39        die "couldn't encrypt $name.age"
 40
 41    printf '%s\n' "saved '$name' to the store."
 42
 43    $git_enabled && git_add_and_commit "./$name.age" "add '$name'"
 44}
 45
 46pw_edit() {
 47    # Prefer /dev/shm because it's an in-memory
 48    # space that we can use to store data without
 49    # having bits laying around in sectors.
 50    tmpdir=/dev/shm
 51    # Fall back to $TMPDIR or /tmp - /dev/shm is Linux-only
 52    # and shared memory space on other operating systems
 53    # have non-standard methods of setup/access.
 54    [ -w /dev/shm ] || tmpdir=${TMPDIR:-/tmp}
 55
 56    # Reimplement mktemp here, because
 57    # mktemp isn't defined in POSIX.
 58    new=true tmpfile=$tmpdir/pa.$(rand_chars 10 'A-Za-z0-9') ||
 59        die "couldn't generate random characters"
 60
 61    (: >"$tmpfile") 2>/dev/null ||
 62        die "couldn't create a shared memory filename"
 63
 64    trap 'rm -f "$tmpfile" "$tmpfile.orig"' EXIT
 65
 66    [ -f "$name.age" ] && new=false &&
 67        { $age --decrypt -i "$identities_file" -o "$tmpfile" "./$name.age" ||
 68            die "couldn't decrypt $name.age"; }
 69
 70    cp "$tmpfile" "$tmpfile.orig"
 71
 72    ${EDITOR:-vi} "$tmpfile" ||
 73        die "EDITOR exited non-zero"
 74
 75    cmp -s "$tmpfile" "$tmpfile.orig" || [ ! -s "$tmpfile" ] && return
 76
 77    mkdir -p "$(dirname -- "$name")" ||
 78        die "couldn't create category '$(dirname -- "$name")'"
 79
 80    $age --encrypt -R "$recipients_file" -o "./$name.age" "$tmpfile" ||
 81        die "couldn't encrypt $name.age"
 82
 83    if $new; then printf '%s\n' "saved '$name' to the store."; fi
 84
 85    $git_enabled && git_add_and_commit "./$name.age" "edit '$name'"
 86}
 87
 88pw_del() {
 89    yn "delete password '$name'?" || return
 90
 91    rm -f "./$name.age"
 92
 93    rmdir -p "$(dirname -- "$name")" 2>/dev/null || :
 94
 95    $git_enabled && git_add_and_commit "./$name.age" "delete '$name'"
 96}
 97
 98pw_show() {
 99    $age --decrypt -i "$identities_file" "./$name.age" ||
100        die "couldn't decrypt $name.age"
101}
102
103pw_list() {
104    find "./$name" -type f -name \*.age | sed 's/..//;s/\.age$//' | sort
105}
106
107pw_move() {
108    mkdir -p "$(dirname -- "$name")" ||
109        die "couldn't create category '$(dirname -- "$name")'"
110
111    mv -- "$src.age" "$name.age"
112
113    if $git_enabled; then
114        git rm -q "./$src.age"
115        git_add_and_commit "./$name.age" "move '$src' to '$name'"
116    fi
117}
118
119git_add_and_commit() {
120    git add "$1" ||
121        die "couldn't git add $1"
122
123    git commit -qm "$2" ||
124        die "couldn't git commit $2"
125}
126
127rand_chars() {
128    # Generate random characters by reading '/dev/urandom' with the
129    # 'tr' command to translate the random bytes into a
130    # configurable character set.
131    #
132    # The 'dd' command is then used to read only the desired length.
133    #
134    # Regarding usage of '/dev/urandom' instead of '/dev/random'.
135    # See: https://www.2uo.de/myths-about-urandom
136    #
137    # $1 = number of chars to receive
138    # $2 = filter for the chars
139    LC_ALL=C tr -dc "$2" </dev/urandom | dd bs=1 count="$1" 2>/dev/null
140}
141
142yn() {
143    printf '%s [y/N]: ' "$1"
144
145    # Enable raw input to allow for a single byte to be read from
146    # stdin without needing to wait for the user to press Return.
147    [ -t 0 ] && stty -echo -icanon
148
149    # Read a single byte from stdin using 'dd'. POSIX 'read' has
150    # no support for single/'N' byte based input from the user.
151    answer=$(dd bs=1 count=1 2>/dev/null)
152
153    # Disable raw input, leaving the terminal how we *should*
154    # have found it.
155    [ -t 0 ] && stty echo icanon
156
157    printf '%s\n' "$answer"
158
159    # Handle the answer here directly, enabling this function's
160    # return status to be used in place of checking for '[yY]'
161    # throughout this program.
162    glob "$answer" '[yY]'
163}
164
165sread() {
166    printf '%s: ' "$2"
167
168    # Disable terminal printing while the user inputs their
169    # password. POSIX 'read' has no '-s' flag which would
170    # effectively do the same thing.
171    [ -t 0 ] && stty -echo
172    read -r "$1"
173    [ -t 0 ] && stty echo
174
175    printf '\n'
176}
177
178glob() {
179    # This is a simple wrapper around a case statement to allow
180    # for simple string comparisons against globs.
181    #
182    # Example: if glob "Hello World" '* World'; then
183    #
184    # Disable this warning as it is the intended behavior.
185    # shellcheck disable=2254
186    case $1 in $2) return 0 ;; esac
187    return 1
188}
189
190die() {
191    printf '%s: %s.\n' "$(basename "$0")" "$1" >&2
192    exit 1
193}
194
195usage() {
196    printf %s "\
197  pa
198    a simple password manager
199
200  commands:
201    [a]dd  [name] - Add a password entry.
202    [d]el  [name] - Delete a password entry.
203    [e]dit [name] - Edit a password entry with ${EDITOR:-vi}.
204    [g]it  [cmd]  - Run git command in the password dir.
205    [l]ist [cat]  - List all entries in a category.
206    [s]how [name] - Show password for an entry.
207    [m]ove [src] [name] - Rename a password entry.
208
209  env vars:
210    data directory:   export PA_DIR=~/.local/share/pa
211    recipients file:  export PA_RECIPIENTS=$PA_DIR/passwords/recipients
212    password length:  export PA_LENGTH=50
213    password pattern: export PA_PATTERN=A-Za-z0-9_-
214    disable tracking: export PA_NOGIT=
215"
216    exit 0
217}
218
219main() {
220    age=$(command -v age || command -v rage) ||
221        die "age not found, install per https://age-encryption.org"
222
223    age_keygen=$(command -v age-keygen || command -v rage-keygen) ||
224        die "age-keygen not found, install per https://age-encryption.org"
225
226    : "${PA_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/pa}"
227
228    glob "$PA_DIR" '/*' ||
229        die "PA_DIR must be an absolute path (got '$PA_DIR')"
230
231    identities_file=$PA_DIR/identities
232
233    if [ -n "$PA_RECIPIENTS" ]; then
234        recipients_file=$PA_RECIPIENTS
235    elif [ -f "$PA_DIR/passwords/recipients" ]; then
236        recipients_file=$PA_DIR/passwords/recipients
237    elif [ -f "$PA_DIR/passwords/.recipients" ]; then
238        recipients_file=$PA_DIR/passwords/.recipients
239    elif [ -f "$PA_DIR/recipients" ]; then
240        recipients_file=$PA_DIR/recipients
241    else
242        recipients_file=$PA_DIR/passwords/recipients
243    fi
244
245    mkdir -p "$PA_DIR/passwords" ||
246        die "couldn't create pa directories"
247
248    cd "$PA_DIR/passwords" ||
249        die "couldn't change to password directory"
250
251    # Ensure that globbing is disabled
252    # to avoid insecurities with word-splitting.
253    set -f
254
255    git_enabled=false
256    [ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null 2>&1 && git_enabled=true
257
258    $git_enabled && [ ! -d .git ] && {
259        git init -q
260
261        # Put something in user config if it's not set globally,
262        # because git doesn't allow to commit without it.
263        git config user.name >/dev/null || git config user.name pa
264        git config user.email >/dev/null || git config user.email ""
265
266        # Configure diff driver for age encrypted files that treats them as
267        # binary and decrypts them when a human-readable diff is requested.
268        git config diff.age.binary true
269        git config diff.age.textconv "$age --decrypt -i '$identities_file'"
270
271        # Assign this diff driver to all passwords.
272        printf '%s\n' '*.age diff=age' >|.gitattributes
273
274        git_add_and_commit . "initial commit"
275    }
276
277    command=$1
278    shift
279
280    glob "$command" 'g*' && {
281        git "$@"
282        exit $?
283    }
284
285    glob "$command" 'm*' && { src=$1 && [ "$src" ] ||
286        die "missing [src] argument"; } && shift
287
288    # Combine the rest of positional arguments into
289    # a name and remove control characters from it
290    # so that a name can always be safely displayed.
291    name=$(printf %s "$*" | LC_ALL=C tr -d '[:cntrl:]')
292
293    glob "$command" '[adesm]*' && [ -z "$name" ] &&
294        die "missing [name] argument"
295
296    glob "$command" '[adesm]*' && { glob "$name" '/*' || glob "$name" '*/' ||
297        glob "$src" '/*' || glob "$src" '*/'; } &&
298        die "name can't start or end with '/'"
299
300    glob "$command" 'l*' && glob "$name" '/*' &&
301        die "category can't start with '/'"
302
303    glob "$name" '../*' || glob "$name" '*/../*' ||
304        glob "$src" '../*' || glob "$src" '*/../*' &&
305        die "category went out of bounds"
306
307    glob "$command" 'm*' && [ ! -f "$src.age" ] &&
308        die "password '$src' doesn't exist"
309
310    glob "$command" '[am]*' && [ -f "$name.age" ] &&
311        die "password '$name' already exists"
312
313    glob "$command" '[ds]*' && [ ! -f "$name.age" ] &&
314        die "password '$name' doesn't exist"
315
316    glob "$command" 'l*' && [ "$name" ] && [ ! -d "$name" ] &&
317        die "category '$name' doesn't exist"
318
319    if command -v age-plugin-yubikey >/dev/null 2>&1; then
320        [ ! -f "$identities_file" ] && [ ! -f "$recipients_file" ] && {
321            yn "generate yubikey identity?" && {
322                age-plugin-yubikey \
323                    --generate \
324                    --name "pa identity" \
325                    --pin-policy never \
326                    --touch-policy always >"$identities_file" ||
327                    die 'failed to generate YubiKey identity file'
328
329                age-plugin-yubikey -l >"$recipients_file" ||
330                    die 'failed to generate YubiKey recipients file'
331            }
332        }
333    fi
334
335    [ -f "$identities_file" ] ||
336        $age_keygen -o "$identities_file" 2>/dev/null
337
338    [ -f "$recipients_file" ] ||
339        $age_keygen -y -o "$recipients_file" "$identities_file" 2>/dev/null
340
341    # Ensure that we leave the terminal in a usable state on Ctrl+C.
342    [ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT
343
344    case $command in
345    a*) pw_add ;;
346    d*) pw_del ;;
347    e*) pw_edit ;;
348    l*) pw_list ;;
349    s*) pw_show ;;
350    m*) pw_move ;;
351    *) usage ;;
352    esac
353}
354
355# Ensure that debug mode is never enabled to
356# prevent the password from leaking.
357set +x
358
359# Prevent accidentally writing to existing
360# files to avoid TOCTOU vulnerabilities.
361set -C
362
363# Restrict permissions of any new files to
364# only the current user.
365umask 077
366
367[ "$1" ] || usage && main "$@"
368