#!/bin/sh
#
# pash - simple password manager.

pw_add() {
    name=$1

    if yn "Generate a password?"; then
        # Generate a password 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.
        #
        # Regarding usage of '/dev/urandom' instead of '/dev/random'.
        # See: https://www.2uo.de/myths-about-urandom
        pass=$(LC_ALL=C tr -dc "${PASH_PATTERN:-_A-Z-a-z-0-9}" < /dev/urandom |
            dd ibs=1 obs=1 count="${PASH_LENGTH:-50}" 2>/dev/null)

    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 "Failed to 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?
    if [ "$PASH_KEYID" ]; then
        set -- --trust-model always -aer "$PASH_KEYID"
    else
        set -- -c
    fi

    # Use 'gpg' 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 '/proc'.
    "$gpg" "$@" -o "$name.gpg" <<-EOF && \
        printf '%s\n' "Saved '$name' to the store."
		$pass
	EOF
}

pw_del() {
    yn "Delete pass file '$1'?" && {
        rm -f "$1.gpg"
        rmdir -p "${1%/*}" 2>/dev/null
    }
}

pw_show() {
    "$gpg" -dq "$1.gpg"
}

pw_copy() {
    # Disable warning against word-splitting as it is safe
    # and intentional (globbing is disabled).
    # shellcheck disable=2086
    : "${PASH_CLIP:=xclip -sel c}"

    # Wait in the background for the password timeout and
    # clear the clipboard when the timer runs out.
    #
    # If the 'sleep' fails, kill the script. This is the
    # simplest method of aborting from a subshell.
    [ "$PASH_TIMEOUT" != off ] && {
        printf 'Clearing clipboard in "%s" seconds.\n' "${PASH_TIMEOUT:=15}"

        sleep "$PASH_TIMEOUT" || kill 0
        $PASH_CLIP </dev/null
    } &

    pw_show "$1" | $PASH_CLIP
}

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

pw_tree() {
    command -v tree >/dev/null 2>&1 ||
        die "'tree' command not found"

    tree --noreport | sed 's/\.gpg$//'
}

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 "\
pash 2.3.0 - simple password manager.

=> [a]dd  [name] - Create a new password entry.
=> [c]opy [name] - Copy entry to the clipboard.
=> [d]el  [name] - Delete a password entry.
=> [l]ist        - List all entries.
=> [s]how [name] - Show password for an entry.
=> [t]ree        - List all entries in a tree.

Using a key pair:  export PASH_KEYID=XXXXXXXX
Password length:   export PASH_LENGTH=50
Password pattern:  export PASH_PATTERN=_A-Z-a-z-0-9
Store location:    export PASH_DIR=~/.local/share/pash
Clipboard tool:    export PASH_CLIP='xclip -sel c'
Clipboard timeout: export PASH_TIMEOUT=15 ('off' to disable)
"
exit 1
}

main() {
    : "${PASH_DIR:=${XDG_DATA_HOME:=$HOME/.local/share}/pash}"

    [ "$1" = '-?' ] || [ -z "$1" ] &&
        usage

    # Look for both 'gpg' and 'gpg2',
    # preferring 'gpg2' if it is available.
    command -v gpg  >/dev/null 2>&1 && gpg=gpg
    command -v gpg2 >/dev/null 2>&1 && gpg=gpg2

    [ "$gpg" ] ||
        die "GPG not found"

    mkdir -p "$PASH_DIR" ||
        die "Couldn't create password directory"

    cd "$PASH_DIR" ||
        die "Can't access password directory"

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

    glob "$1" '[cds]*' && [ ! -f "$2.gpg" ] &&
        die "Pass file '$2' doesn't exist"

    glob "$1" 'a*' && [ -f "$2.gpg" ] &&
        die "Pass file '$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%/*}'"; }

    # Set 'GPG_TTY' to the current 'TTY' if it
    # is unset. Fixes a somewhat rare `gpg` issue.
    export GPG_TTY=${GPG_TTY:-$(tty)}

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

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

    case $1 in
        a*) pw_add  "$2" ;;
        c*) pw_copy "$2" ;;
        d*) pw_del  "$2" ;;
        s*) pw_show "$2" ;;
        l*) pw_list ;;
        t*) pw_tree ;;
        *)  usage
    esac
}

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

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

main "$@"
