small pixel drawing of a pufferfish pa

pa

#!/bin/sh
#
# pa - a simple password manager based on age

pw_add() {
    name=$1

    if yn "Generate a password?"; then
        pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-_A-Z-a-z-0-9}")
    else
        # 'sread()' is a simple wrapper function around 'read'
        # to prevent user input from being printed to the terminal.
        sread pass "Enter password"
        sread pass2 "Enter password (again)"

        # Disable this check as we dynamically populate the two
        # passwords using the 'sread()' function.
        # shellcheck disable=2154
        [ "$pass" = "$pass2" ] || die "Passwords do not match"
    fi

    [ "$pass" ] || die "Couldn't generate a password"

    # Mimic the use of an array for storing arguments by... using
    # the function's argument list. This is very apt isn't it?
    set -- -c

    # Use 'age' to store the password in an encrypted file.
    # A heredoc is used here instead of a 'printf' to avoid
    # leaking the password through the '/proc' filesystem.
    #
    # Heredocs are sometimes implemented via temporary files,
    # however this is typically done using 'mkstemp()' which
    # is more secure than a leak in '/proc'.
    age --encrypt -R "$recipients_file" -o "$name.age" <<-EOF &&
		$pass
	EOF
        printf '%s\n' "Saved '$name' to the store."
}

pw_edit() {
    name=$1

    [ -f "$name.age" ] ||
        die "Couldn't access $name"

    # Prefer /dev/shm because it's an in-memory
    # space that we can use to store data without
    # having bits laying around in sectors.
    tmpdir=/dev/shm
    # Fall back to /tmp - /dev/shm is Linux-only & /tmp
    # and shared memory space on other operating systems
    # have non-standard methods of setup/access.
    [ -w /dev/shm ] ||
        tmpdir=/tmp

    # Reimplement mktemp here, because
    # mktemp isn't defined in POSIX
    editdir="$tmpdir/pa.$(rand_chars 8 '[:alnum:]')"

    tmpfile="$editdir/$name.age"

    mkdir "$editdir" ||
        die "Couldn't create shared memory dir"

    trap 'rm -rf $editdir' EXIT

    # Handle nested items (/foo/bar.age)
    mkdir -p "$(dirname "$tmpfile")"

    age --decrypt -i "$identities_file" -o "$tmpfile" "$name.age" ||
        die "Couldn't decrypt $name.age"

    "${EDITOR:-vi}" "$tmpfile"

    age --encrypt -R "$recipients_file" -o "$name.age" "$tmpfile" ||
        die "Couldn't encrypt $name.age"
}

pw_del() {
    yn "Delete pass file '$1'?" && {
        rm -f "$1.age"

        # Remove empty parent directories of a password
        # entry. It's fine if this fails as it means that
        # another entry also lives in the same directory.
        rmdir -p "${1%/*}" 2>/dev/null || :
    }
}

pw_show() {
    age --decrypt -i "$identities_file" "$1.age" ||
        die "Couldn't decrypt $1.age"
}

pw_list() {
    find . -type f -name \*.age | sed 's/..//;s/\.age$//'
}

rand_chars() {
    # Generate random characters by reading '/dev/urandom' with the
    # 'tr' command to translate the random bytes into a
    # configurable character set.
    #
    # The 'dd' command is then used to read only the desired
    # password length, since head -c isn't POSIX compliant.
    #
    # Regarding usage of '/dev/urandom' instead of '/dev/random'.
    # See: https://www.2uo.de/myths-about-urandom
    #
    # $1 = number of chars to receive
    # $2 = filter for the chars
    #
    # TODO: add more safety/compat here in case /dev/urandom doesn't exist
    LC_ALL=C tr -dc "$2" </dev/urandom |
        dd ibs=1 obs=1 count="$1" 2>/dev/null
}

yn() {
    printf '%s [y/n]: ' "$1"

    # Enable raw input to allow for a single byte to be read from
    # stdin without needing to wait for the user to press Return.
    stty -icanon

    # Read a single byte from stdin using 'dd'. POSIX 'read' has
    # no support for single/'N' byte based input from the user.
    answer=$(dd ibs=1 count=1 2>/dev/null)

    # Disable raw input, leaving the terminal how we *should*
    # have found it.
    stty icanon

    printf '\n'

    # Handle the answer here directly, enabling this function's
    # return status to be used in place of checking for '[yY]'
    # throughout this program.
    glob "$answer" '[yY]'
}

sread() {
    printf '%s: ' "$2"

    # Disable terminal printing while the user inputs their
    # password. POSIX 'read' has no '-s' flag which would
    # effectively do the same thing.
    stty -echo
    read -r "$1"
    stty echo

    printf '\n'
}

glob() {
    # This is a simple wrapper around a case statement to allow
    # for simple string comparisons against globs.
    #
    # Example: if glob "Hello World" '* World'; then
    #
    # Disable this warning as it is the intended behavior.
    # shellcheck disable=2254
    case $1 in $2) return 0 ;; esac
    return 1
}

die() {
    printf 'error: %s.\n' "$1" >&2
    exit 1
}

usage() {
    printf %s "\
  pa
    a simple password manager based on age

  commands:
    [a]dd  [name] - Add a password entry.
    [d]el  [name] - Delete a password entry.
    [e]dit [name] - Edit a password entry with $EDITOR.
    [l]ist        - List all entries.
    [s]how [name] - Show password for an entry.

  env vars:
    Password length:   export PA_LENGTH=50
    Password pattern:  export PA_PATTERN=_A-Z-a-z-0-9
    Password dir:      export PA_DIR=~/.local/share/pa/passwords
"
    exit 0
}

main() {
    basedir="${XDG_DATA_HOME:=$HOME/.local/share}/pa"
    : "${PA_DIR:=$basedir/passwords}"
    identities_file="$basedir/identities"
    recipients_file="$basedir/recipients"

    mkdir -p "$basedir" "$PA_DIR" ||
        die "Couldn't create pa directories"

    cd "$PA_DIR" ||
        die "Couldn't change to password directory"

    # Move any passwords hanging out in the old dir
    # for backwards-compat reasons
    mv "$basedir"/*.age "$PA_DIR" 2>/dev/null

    # Ensure that globbing is disabled
    # to avoid insecurities with word-splitting.
    set -f

    command -v age >/dev/null 2>&1 ||
        die "age not found, install per https://github.com/FiloSottile/age"

    command -v age-keygen >/dev/null 2>&1 ||
        die "age-keygen not found, install per https://github.com/FiloSottile/age"

    glob "$1" '[ades]*' && [ -z "$2" ] &&
        die "Missing [name] argument"

    glob "$1" '[des]*' && [ ! -f "$2.age" ] &&
        die "Password '$2' doesn't exist"

    glob "$1" 'a*' && [ -f "$2.age" ] &&
        die "Password '$2' already exists"

    glob "$2" '*/*' && glob "$2" '*../*' &&
        die "Category went out of bounds"

    glob "$2" '/*' &&
        die "Category can't start with '/'"

    glob "$2" '*/*' && { mkdir -p "${2%/*}" ||
        die "Couldn't create category '${2%/*}'"; }

    # Restrict permissions of any new files to
    # only the current user.
    umask 077

    # First, copy any existing identities files from the old
    # storage location to the new one for backwards compat.
    # Then, attempt key generation.
    [ -f "$identities_file" ] ||
        cp ~/.age/key.txt "$identities_file" 2>/dev/null ||
        age-keygen -o "$identities_file" 2>/dev/null

    [ -f "$recipients_file" ] ||
        age-keygen -y -o "$recipients_file" "$identities_file" 2>/dev/null

    # Ensure that we leave the terminal in a usable
    # state on exit or Ctrl+C.
    [ -t 1 ] && trap 'stty echo icanon' INT EXIT

    case $1 in
    a*) pw_add "$2" ;;
    d*) pw_del "$2" ;;
    e*) pw_edit "$2" ;;
    l*) pw_list ;;
    s*) pw_show "$2" ;;
    *) usage ;;
    esac
}

# Ensure that debug mode is never enabled to
# prevent the password from leaking.
set +x

[ "$1" ] || usage && main "$@"