main dotfiles / bin / pa
Eric Bower  ·  2026-07-21
  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    password length:  export PA_LENGTH=50
212    password pattern: export PA_PATTERN=A-Za-z0-9_-
213    disable tracking: export PA_NOGIT=
214"
215    exit 0
216}
217
218main() {
219    age=$(command -v age || command -v rage) ||
220        die "age not found, install per https://age-encryption.org"
221
222    age_keygen=$(command -v age-keygen || command -v rage-keygen) ||
223        die "age-keygen not found, install per https://age-encryption.org"
224
225    : "${PA_DIR:=${XDG_DATA_HOME:-$HOME/.local/share}/pa}"
226
227    glob "$PA_DIR" '/*' ||
228        die "PA_DIR must be an absolute path (got '$PA_DIR')"
229
230    identities_file=$PA_DIR/identities
231    recipients_file=$PA_DIR/recipients
232
233    mkdir -p "$PA_DIR/passwords" ||
234        die "couldn't create pa directories"
235
236    cd "$PA_DIR/passwords" ||
237        die "couldn't change to password directory"
238
239    # Ensure that globbing is disabled
240    # to avoid insecurities with word-splitting.
241    set -f
242
243    git_enabled=false
244    [ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null 2>&1 && git_enabled=true
245
246    $git_enabled && [ ! -d .git ] && {
247        git init -q
248
249        # Put something in user config if it's not set globally,
250        # because git doesn't allow to commit without it.
251        git config user.name >/dev/null || git config user.name pa
252        git config user.email >/dev/null || git config user.email ""
253
254        # Configure diff driver for age encrypted files that treats them as
255        # binary and decrypts them when a human-readable diff is requested.
256        git config diff.age.binary true
257        git config diff.age.textconv "$age --decrypt -i '$identities_file'"
258
259        # Assign this diff driver to all passwords.
260        printf '%s\n' '*.age diff=age' >.gitattributes
261
262        git_add_and_commit . "initial commit"
263    }
264
265    command=$1
266    shift
267
268    glob "$command" 'g*' && {
269        git "$@"
270        exit $?
271    }
272
273    glob "$command" 'm*' && { src=$1 && [ "$src" ] ||
274        die "missing [src] argument"; } && shift
275
276    # Combine the rest of positional arguments into
277    # a name and remove control characters from it
278    # so that a name can always be safely displayed.
279    name=$(printf %s "$*" | LC_ALL=C tr -d '[:cntrl:]')
280
281    glob "$command" '[adesm]*' && [ -z "$name" ] &&
282        die "missing [name] argument"
283
284    glob "$command" '[adesm]*' && { glob "$name" '/*' || glob "$name" '*/' ||
285        glob "$src" '/*' || glob "$src" '*/'; } &&
286        die "name can't start or end with '/'"
287
288    glob "$command" 'l*' && glob "$name" '/*' &&
289        die "category can't start with '/'"
290
291    glob "$name" '../*' || glob "$name" '*/../*' ||
292        glob "$src" '../*' || glob "$src" '*/../*' &&
293        die "category went out of bounds"
294
295    glob "$command" 'm*' && [ ! -f "$src.age" ] &&
296        die "password '$src' doesn't exist"
297
298    glob "$command" '[am]*' && [ -f "$name.age" ] &&
299        die "password '$name' already exists"
300
301    glob "$command" '[ds]*' && [ ! -f "$name.age" ] &&
302        die "password '$name' doesn't exist"
303
304    glob "$command" 'l*' && [ "$name" ] && [ ! -d "$name" ] &&
305        die "category '$name' doesn't exist"
306
307    if command -v age-plugin-yubikey >/dev/null 2>&1; then
308        [ ! -f "$identities_file" ] && [ ! -f "$recipients_file" ] && {
309            yn "generate yubikey identity?" && {
310                age-plugin-yubikey \
311                    --generate \
312                    --name "pa identity" \
313                    --pin-policy never \
314                    --touch-policy always >"$identities_file" ||
315                    die 'failed to generate YubiKey identity file'
316
317                age-plugin-yubikey -l >"$recipients_file" ||
318                    die 'failed to generate YubiKey recipients file'
319            }
320        }
321    fi
322
323    [ -f "$identities_file" ] ||
324        $age_keygen -o "$identities_file" 2>/dev/null
325
326    [ -f "$recipients_file" ] ||
327        $age_keygen -y -o "$recipients_file" "$identities_file" 2>/dev/null
328
329    # Ensure that we leave the terminal in a usable state on Ctrl+C.
330    [ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT
331
332    case $command in
333    a*) pw_add ;;
334    d*) pw_del ;;
335    e*) pw_edit ;;
336    l*) pw_list ;;
337    s*) pw_show ;;
338    m*) pw_move ;;
339    *) usage ;;
340    esac
341}
342
343# Ensure that debug mode is never enabled to
344# prevent the password from leaking.
345set +x
346
347# Prevent accidentally writing to existing
348# files to avoid TOCTOU vulnerabilities.
349set -C
350
351# Restrict permissions of any new files to
352# only the current user.
353umask 077
354
355[ "$1" ] || usage && main "$@"
356