diff --git a/.gitignore b/.gitignore index 780a118..f5fe9d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ purse.*.tar -purse.index +purse.index* safe/ diff --git a/README.md b/README.md index ee7765f..0499a36 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,35 @@ -# Purse - Purse is a fork of [drduh/pwd.sh](https://github.com/drduh/pwd.sh). Both programs are Bash shell scripts which use [GnuPG](https://www.gnupg.org/) to manage passwords and other secrets in encrypted text files. Purse is based on asymmetric (public-key) authentication, while pwd.sh is based on symmetric (password-based) authentication. -While both scripts use a trusted crypto implementation (GnuPG) and safely handle passwords (never saving plaintext to disk, only using shell built-ins), Purse eliminates the need to remember a master password - just plug in a YubiKey, enter the PIN, then touch it to decrypt a password to clipboard. +While both scripts use a trusted crypto implementation (GnuPG) and safely handle passwords (never saving plaintext to disk, only using shell built-ins), Purse eliminates the need to remember a main passphrase - just plug in a YubiKey, enter the PIN, then touch it to decrypt a password to clipboard. -# Release notes +# Install -See [Releases](https://github.com/drduh/Purse/releases) +This script requires a GnuPG identity - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up. -# Use - -This script requires a GnuPG identity - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up. Multiple identities stored on several YubiKeys are recommended for improved durability and reliability. - -Clone the repository: +For the latest version, clone the repository or download the script directly: ```console git clone https://github.com/drduh/Purse -``` -Or download the script directly: - -```console wget https://github.com/drduh/Purse/blob/master/purse.sh ``` +Versioned [Releases](https://github.com/drduh/Purse/releases) are also available. + +# Use + Run the script interactively using `./purse.sh` or symlink to a directory in `PATH`: -* Type `w` to write a password -* Type `r` to read a password -* Type `l` to list passwords -* Type `b` to create an archive for backup -* Type `h` to print the help text +- `w` to write a password +- `r` to read a password +- `l` to list passwords +- `b` to create an archive for backup +- `h` to print the help text Options can also be passed on the command line. -Example usage: - Create a 20-character password for `userName`: ```console @@ -50,7 +42,7 @@ Read password for `userName`: ./purse.sh r userName ``` -Passwords are stored with a timestamp for revision control. The most recent version is copied to clipboard on read. To list all passwords or read a specific version of a password: +Passwords are stored with an epoch timestamp for revision control. The most recent version is copied to clipboard on read. To list all passwords or read a specific version of a password: ```console ./purse.sh l @@ -70,8 +62,27 @@ Restore an archive from backup: tar xvf purse*tar ``` -**Note** For additional privacy, the recipient key ID is **not** included in metadata (`throw-keyids` option). +# Configure -The password index file can also be encrypted by changing the `encrypt_index` variable to `true` in the script, although two touches will be required for two separate decryption operations. +Several customizable options and features are also available, and can be configured with environment variables, for example in the [shell rc](https://github.com/drduh/config/blob/master/zshrc) file: -See [config/gpg.conf](https://github.com/drduh/config/blob/master/gpg.conf) for additional configuration options. +Variable | Description | Default | Values +-|-|-|- +`PURSE_TIME` | seconds to clear password from clipboard/screen | `10` | any valid integer +`PURSE_LEN` | default generated password length | `14` | any valid integer +`PURSE_COPY` | copy password to clipboard before write | unset (disabled) | `1` or `true` to enable +`PURSE_DAILY` | create daily backup archive on write | unset (disabled) | `1` or `true` to enable +`PURSE_ENCIX` | encrypt index for additional privacy; 2 YubiKey touches will be required for separate decryption operations | unset (disabled) | `1` or `true` to enable +`PURSE_COMMENT` | **unencrypted** comment to include in index and safe files | unset | any valid string +`PURSE_CHARS` | character set for passwords | `[:alnum:]!?@#$%^&*();:+=` | any valid characters +`PURSE_DEST` | password output destination, will set to `screen` without clipboard | `clipboard` | `clipboard` or `screen` +`PURSE_ECHO` | character used to echo password input | `*` | any valid character +`PURSE_SAFE` | safe directory name | `safe` | any valid string +`PURSE_INDEX` | index file name | `purse.index` | any valid string +`PURSE_BACKUP` | backup archive file name | `purse.$hostname.$today.tar` | any valid string + +**Note** For additional privacy, the recipient key ID is **not** included in metadata (GnuPG `throw-keyids` option). + + + +See [config/gpg.conf](https://github.com/drduh/config/blob/master/gpg.conf) for additional GnuPG options. diff --git a/purse.sh b/purse.sh index c869345..205c47e 100755 --- a/purse.sh +++ b/purse.sh @@ -1,54 +1,70 @@ #!/usr/bin/env bash # https://github.com/drduh/Purse/blob/master/purse.sh +#set -x # uncomment to debug set -o errtrace set -o nounset set -o pipefail -#set -x # uncomment to debug - umask 077 - -cb_timeout=10 # seconds to keep password on clipboard -daily_backup="false" # if true, create daily archive on write -encrypt_index="false" # if true, requires 2 touches to decrypt -pass_copy="false" # if true, keep password on clipboard before write -pass_len=14 # default password length -pass_chars="[:alnum:]!@#$%^&*();:+=" - -gpgconf="${HOME}/.gnupg/gpg.conf" -backuptar="${PURSE_BACKUP:=purse.$(hostname).$(date +%F).tar}" -safeix="${PURSE_INDEX:=purse.index}" -safedir="${PURSE_SAFE:=safe}" +export LC_ALL="C" now="$(date +%s)" +today="$(date +%F)" copy="$(command -v xclip || command -v pbcopy)" gpg="$(command -v gpg || command -v gpg2)" -script="$(basename "${BASH_SOURCE}")" +gpg_conf="${GNUPGHOME}/gpg.conf" + +clip_dest="${PURSE_DEST:=clipboard}" # set to 'screen' to print to stdout +clip_timeout="${PURSE_TIME:=10}" # seconds to clear clipboard/screen +comment="${PURSE_COMMENT:=}" # *unencrypted* comment in files +daily_backup="${PURSE_DAILY:=}" # daily backup archive on write +encrypt_index="${PURSE_ENCIX:=}" # also keep index encrypted +pass_copy="${PURSE_COPY:=}" # copy password before write +pass_echo="${PURSE_ECHO:=*}" # show "*" when typing passwords +pass_len="${PURSE_LEN:=14}" # default password length +safe_dir="${PURSE_SAFE:=safe}" # safe directory name +safe_ix="${PURSE_INDEX:=purse.index}" # index file name +safe_backup="${PURSE_BACKUP:=purse.$(hostname).${today}.tar}" +pass_chars="${PURSE_CHARS:='[:alnum:]!?@#$%^&*();:+='}" + +trap cleanup EXIT INT TERM +cleanup () { + # "Lock" files on trapped exits. + + ret=$? + chmod -R 0000 \ + "${safe_dir}" "${safe_ix}" 2>/dev/null + exit ${ret} +} fail () { - # Print an error message and exit. + # Print an error in red and exit. - tput setaf 1 ; printf "\nError: %s\n" "${1}" ; tput sgr0 + tput setaf 1 ; printf "\nERROR: %s\n" "${1}" ; tput sgr0 exit 1 } +warn () { + # Print a warning in yellow. + + tput setaf 3 ; printf "\nWARNING: %s\n" "${1}" ; tput sgr0 +} + get_pass () { # Prompt for a password. - password="" - prompt="${1}" + prompt=" ${1}" + printf "\n" while IFS= read -p "${prompt}" -r -s -n 1 char ; do - if [[ ${char} == $'\0' ]] ; then - break + if [[ ${char} == $'\0' ]] ; then break elif [[ ${char} == $'\177' ]] ; then - if [[ -z "${password}" ]] ; then - prompt="" + if [[ -z "${password}" ]] ; then prompt="" else prompt=$'\b \b' password="${password%?}" fi else - prompt="*" + prompt="${pass_echo}" password+="${char}" fi done @@ -64,39 +80,35 @@ decrypt () { encrypt () { # Encrypt to a group of hidden recipients. - ${gpg} --encrypt --armor --batch --yes --throw-keyids \ + ${gpg} --encrypt --armor --batch --yes \ --hidden-recipient "purse_keygroup" \ - --output "${1}" "${2}" + --throw-keyids --comment "${comment}" \ + --output "${1}" "${2}" 2>/dev/null } read_pass () { # Read a password from safe. - if [[ ! -s ${safeix} ]] ; then fail "${safeix} not found" ; fi + if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi - username="" while [[ -z "${username}" ]] ; do if [[ -z "${2+x}" ]] ; then read -r -p " Username: " username else username="${2}" ; fi done - if [[ "${encrypt_index}" = "true" ]] ; then - prompt_key "index" - - spath=$(decrypt "${safeix}" | \ + if [[ -n "${encrypt_index}" ]] ; then prompt_key "index" + spath=$(decrypt "${safe_ix}" | \ grep -F "${username}" | tail -1 | cut -d ":" -f2) || \ - fail "Decryption failed" - else - spath=$(grep -F "${username}" "${safeix}" | \ - tail -1 | cut -d ":" -f2) + fail "Secret not available" + else spath=$(grep -F "${username}" "${safe_ix}" | \ + tail -1 | cut -d ":" -f2) fi prompt_key "password" - if [[ -s "${spath}" ]] ; then clip <(decrypt "${spath}" | head -1) || \ - fail "Decryption failed" + fail "Failed to decrypt ${spath}" else fail "Secret not available" fi } @@ -104,116 +116,114 @@ read_pass () { prompt_key () { # Print a message if safe file exists. - if [[ -f "${safeix}" ]] ; then + if [[ -f "${safe_ix}" ]] ; then printf "\n Touch key to access %s ...\n" "${1}" fi } gen_pass () { - # Generate a password using GPG. + # Generate a password from urandom. if [[ -z "${3+x}" ]] ; then read -r -p " - Password length (default: ${pass_len}): " length else length="${3}" ; fi - if [[ ${length} =~ ^[0-9]+$ ]] ; then pass_len=${length} ; fi + if [[ "${length}" =~ ^[0-9]+$ ]] ; then + pass_len="${length}" + fi - LC_LANG=C tr -dc "${pass_chars}" < /dev/urandom | \ + tr -dc "${pass_chars}" < /dev/urandom | \ fold -w "${pass_len}" | head -1 } write_pass () { # Write a password and update the index. - if [[ "${pass_copy}" = "true" ]] ; then + spath="${safe_dir}/$(tr -dc "[:lower:]" < /dev/urandom | \ + fold -w10 | head -1)" + + if [[ -n "${pass_copy}" ]] ; then clip <(printf '%s' "${userpass}") fi - fpath="$(LC_LANG=C tr -dc '[:lower:]' < /dev/urandom | fold -w10 | head -1)" - spath="${safedir}/${fpath}" printf '%s\n' "${userpass}" | \ encrypt "${spath}" - || \ - fail "Failed to put ${spath}" - userpass="" + fail "Failed saving ${spath}" - if [[ "${encrypt_index}" = "true" ]] ; then + if [[ -n "${encrypt_index}" ]] ; then prompt_key "index" - ( if [[ -f "${safeix}" ]] ; then - decrypt "${safeix}" || return ; fi + ( if [[ -f "${safe_ix}" ]] ; then + decrypt "${safe_ix}" || return ; fi printf "%s@%s:%s\n" "${username}" "${now}" "${spath}") | \ - encrypt "${safeix}.${now}" - || \ - fail "Failed to put ${safeix}.${now}" - mv "${safeix}.${now}" "${safeix}" + encrypt "${safe_ix}.${now}" - && \ + mv "${safe_ix}.${now}" "${safe_ix}" || \ + fail "Failed saving ${safe_ix}.${now}" else - printf "%s@%s:%s\n" "${username}" "${now}" "${spath}" >> "${safeix}" + printf "%s@%s:%s\n" \ + "${username}" "${now}" "${spath}" >> "${safe_ix}" fi } list_entry () { # Decrypt the index to list entries. - if [[ ! -s ${safeix} ]] ; then fail "${safeix} not found" ; fi + if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi - if [[ "${encrypt_index}" = "true" ]] ; then - prompt_key "index" - decrypt "${safeix}" || fail "Decryption failed" - else - printf "\n" - cat "${safeix}" + if [[ -n "${encrypt_index}" ]] ; then prompt_key "index" + decrypt "${safe_ix}" || fail "${safe_ix} not available" + else printf "\n" ; cat "${safe_ix}" fi } backup () { # Archive index, safe and configuration. - if [[ -f "${safeix}" && -d "${safedir}" ]] ; then - cp "${gpgconf}" "gpg.conf.${now}" - tar --create --file "${backuptar}" \ - "${safeix}" "${safedir}" "gpg.conf.${now}" "${script}" - rm "gpg.conf.${now}" - else fail "Nothing to archive" ; fi - - printf "\nArchived %s\n" "${backuptar}" + if [[ ! -f ${safe_backup} ]] ; then + if [[ -f "${safe_ix}" && -d "${safe_dir}" ]] ; then + cp "${gpg_conf}" "gpg.conf.${today}" + tar cf "${safe_backup}" "${safe_dir}" "${safe_ix}" \ + "${BASH_SOURCE}" "gpg.conf.${today}" && \ + printf "\nArchived %s\n" "${safe_backup}" + rm -f "gpg.conf.${today}" + else fail "Nothing to archive" ; fi + else warn "${safe_backup} exists, skipping archive" ; fi } clip () { - # Use clipboard and clear after timeout. + # Use clipboard or stdout and clear after timeout. - ${copy} < "${1}" + if [[ "${clip_dest}" = "screen" ]] ; then + printf '\n%s\n' "$(cat ${1})" + else "${copy}" < "${1}" ; fi printf "\n" - shift - while [ $cb_timeout -gt 0 ] ; do - printf "\r\033[KPassword on clipboard! Clearing in %.d" $((cb_timeout--)) - sleep 1 + while [[ "${clip_timeout}" -gt 0 ]] ; do + printf "\r\033[K Password on %s! Clearing in %.d" \ + "${clip_dest}" "$((clip_timeout--))" ; sleep 1 done + printf "\r\033[K Clearing password from %s ..." "${clip_dest}" - printf "\n" - printf "" | ${copy} + if [[ "${clip_dest}" = "screen" ]] ; then clear + else printf "\n" ; printf "" | "${copy}" ; fi } setup_keygroup() { - # Configure GPG keygroup. + # Configure one or more recipients. purse_keygroup="group purse_keygroup =" keyid="" recommend="$(${gpg} -K | grep "sec#" | \ awk -F "/" '{print $2}' | cut -c-18 | tr "\n" " ")" - printf "\n Setting up GPG key group ... + printf "\n Setting up keygroup ...\n + Found recommended key IDs: %s\n + Enter one or more key IDs, preferred one last\n" "${recommend}" - Found key IDs: %s - - Enter backup key IDs first, preferred key IDs last. - " "${recommend}" - - while [[ -z "${keyid}" ]] ; do - read -r -p " + while [[ -z "${keyid}" ]] ; do read -r -p " Key ID or Enter to continue: " keyid if [[ -z "${keyid}" ]] ; then - printf "%s\n" "$purse_keygroup" >> "${gpgconf}" + printf "%s\n" "${purse_keygroup}" >> "${gpg_conf}" break fi purse_keygroup="${purse_keygroup} ${keyid}" @@ -224,18 +234,18 @@ setup_keygroup() { new_entry () { # Prompt for username and password. - username="" while [[ -z "${username}" ]] ; do if [[ -z "${2+x}" ]] ; then read -r -p " Username: " username else username="${2}" ; fi done - if [[ -z "${3+x}" ]] ; then get_pass " - Password for \"${username}\" (Enter to generate): " + if [[ -z "${3+x}" ]] ; then + get_pass "Password for \"${username}\" (Enter to generate): " userpass="${password}" fi + printf "\n" if [[ -z "${password}" ]] ; then userpass=$(gen_pass "$@") fi @@ -245,82 +255,62 @@ print_help () { # Print help text. printf """ - Purse is a Bash shell script to manage passwords with GnuPG asymmetric encryption. It is designed and recommended to be used with Yubikey as the secret key storage. - - Purse can be used interactively or by passing one of the following options: - + Purse is a Bash shell script to manage passwords with GnuPG asymmetric encryption. It is designed and recommended to be used with YubiKey as the secret key storage.\n + Purse can be used interactively or by passing one of the following options:\n * 'w' to write a password * 'r' to read a password * 'l' to list passwords - * 'b' to create an archive for backup - - Example usage: - - * Generate a 30 character password for 'userName': - ./purse.sh w userName 30 - - * Copy the password for 'userName' to clipboard: - ./purse.sh r userName - - * List stored passwords and copy a specific version: - ./purse.sh l - ./purse.sh r userName@1574723625 - - * Create an archive for backup: - ./purse.sh b - - * Restore an archive from backup: - tar xvf purse*tar""" + * 'b' to create an archive for backup\n + Options can also be passed on the command line.\n + * Create a 20-character password for userName: + ./purse.sh w userName 20\n + * Read password for userName: + ./purse.sh r userName\n + * Passwords are stored with an epoch timestamp for revision control. The most recent version is copied to clipboard on read. To list all passwords or read a specific version of a password: + ./purse.sh l + ./purse.sh r userName@1574723625\n + * Create an archive for backup: + ./purse.sh b\n + * Restore an archive from backup: + tar xvf purse*tar\n""" } -if [[ -z ${gpg} && ! -x ${gpg} ]] ; then fail "GnuPG is not available" ; fi +if [[ -z "${gpg}" || ! -x "${gpg}" ]] ; then fail "GnuPG is not available" ; fi -if [[ -z ${copy} && ! -x ${copy} ]] ; then fail "Clipboard is not available" ; fi +if [[ ! -f "${gpg_conf}" ]] ; then fail "GnuPG config is not available" ; fi -if [[ ! -f ${gpgconf} ]] ; then fail "GnuPG config is not available" ; fi +if [[ ! -d "${safe_dir}" ]] ; then mkdir -p "${safe_dir}" ; fi -if [[ ! -d "${safedir}" ]] ; then mkdir -p "${safedir}" ; fi +chmod -R 0700 "${safe_dir}" "${safe_ix}" 2>/dev/null -chmod -R 0600 "${safeix}" 2>/dev/null -chmod -R 0700 "${safedir}" 2>/dev/null +if [[ -z "${copy}" || ! -x "${copy}" ]] ; then + warn "Clipboard not available, passwords will print to screen/stdout!" + clip_dest="screen" +fi +username="" password="" action="" + if [[ -n "${1+x}" ]] ; then action="${1}" ; fi -while [[ -z "${action}" ]] ; do - read -r -n 1 -p " +while [[ -z "${action}" ]] ; do read -r -n 1 -p " Read or Write (or Help for more options): " action printf "\n" done -if [[ "${action}" =~ ^([hH])$ ]] ; then - print_help - -elif [[ "${action}" =~ ^([bB])$ ]] ; then - backup - -elif [[ "${action}" =~ ^([lL])$ ]] ; then - list_entry - +if [[ "${action}" =~ ^([rR])$ ]] ; then read_pass "$@" elif [[ "${action}" =~ ^([wW])$ ]] ; then - purse_keygroup=$(grep "group purse_keygroup" "${gpgconf}") + purse_keygroup="$(grep "group purse_keygroup" "${gpg_conf}")" if [[ -z "${purse_keygroup}" ]] ; then setup_keygroup fi printf "\n %s\n" "${purse_keygroup}" - new_entry "$@" write_pass - - if [[ "${daily_backup}" = "true" ]] ; then - if [[ ! -f ${backuptar} ]] ; then - backup - fi - fi - -else read_pass "$@" ; fi - -chmod -R 0400 "${safeix}" "${safedir}" 2>/dev/null + if [[ -n "${daily_backup}" ]] ; then backup ; fi +elif [[ "${action}" =~ ^([lL])$ ]] ; then list_entry +elif [[ "${action}" =~ ^([bB])$ ]] ; then backup +else print_help ; fi tput setaf 2 ; printf "\nDone\n" ; tput sgr0