1
0
mirror of https://github.com/octoleo/Purse.git synced 2024-06-01 14:10:47 +00:00

Merge pull request #7 from drduh/wip-26mar24

More config options, trap exits, stdout support
This commit is contained in:
drduh 2024-03-27 17:12:09 +00:00 committed by GitHub
commit 8f09cf87e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 170 additions and 169 deletions

2
.gitignore vendored
View File

@ -1,3 +1,3 @@
purse.*.tar purse.*.tar
purse.index purse.index*
safe/ safe/

View File

@ -1,43 +1,35 @@
# Purse
Purse is a fork of [drduh/pwd.sh](https://github.com/drduh/pwd.sh). 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. 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 For the latest version, clone the repository or download the script directly:
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:
```console ```console
git clone https://github.com/drduh/Purse git clone https://github.com/drduh/Purse
```
Or download the script directly:
```console
wget https://github.com/drduh/Purse/blob/master/purse.sh 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`: Run the script interactively using `./purse.sh` or symlink to a directory in `PATH`:
* Type `w` to write a password - `w` to write a password
* Type `r` to read a password - `r` to read a password
* Type `l` to list passwords - `l` to list passwords
* Type `b` to create an archive for backup - `b` to create an archive for backup
* Type `h` to print the help text - `h` to print the help text
Options can also be passed on the command line. Options can also be passed on the command line.
Example usage:
Create a 20-character password for `userName`: Create a 20-character password for `userName`:
```console ```console
@ -50,7 +42,7 @@ Read password for `userName`:
./purse.sh r 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 ```console
./purse.sh l ./purse.sh l
@ -70,8 +62,27 @@ Restore an archive from backup:
tar xvf purse*tar 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.

266
purse.sh
View File

@ -1,54 +1,70 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# https://github.com/drduh/Purse/blob/master/purse.sh # https://github.com/drduh/Purse/blob/master/purse.sh
#set -x # uncomment to debug
set -o errtrace set -o errtrace
set -o nounset set -o nounset
set -o pipefail set -o pipefail
#set -x # uncomment to debug
umask 077 umask 077
export LC_ALL="C"
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}"
now="$(date +%s)" now="$(date +%s)"
today="$(date +%F)"
copy="$(command -v xclip || command -v pbcopy)" copy="$(command -v xclip || command -v pbcopy)"
gpg="$(command -v gpg || command -v gpg2)" 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 () { 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 exit 1
} }
warn () {
# Print a warning in yellow.
tput setaf 3 ; printf "\nWARNING: %s\n" "${1}" ; tput sgr0
}
get_pass () { get_pass () {
# Prompt for a password. # Prompt for a password.
password="" prompt=" ${1}"
prompt="${1}" printf "\n"
while IFS= read -p "${prompt}" -r -s -n 1 char ; do while IFS= read -p "${prompt}" -r -s -n 1 char ; do
if [[ ${char} == $'\0' ]] ; then if [[ ${char} == $'\0' ]] ; then break
break
elif [[ ${char} == $'\177' ]] ; then elif [[ ${char} == $'\177' ]] ; then
if [[ -z "${password}" ]] ; then if [[ -z "${password}" ]] ; then prompt=""
prompt=""
else else
prompt=$'\b \b' prompt=$'\b \b'
password="${password%?}" password="${password%?}"
fi fi
else else
prompt="*" prompt="${pass_echo}"
password+="${char}" password+="${char}"
fi fi
done done
@ -64,39 +80,35 @@ decrypt () {
encrypt () { encrypt () {
# Encrypt to a group of hidden recipients. # Encrypt to a group of hidden recipients.
${gpg} --encrypt --armor --batch --yes --throw-keyids \ ${gpg} --encrypt --armor --batch --yes \
--hidden-recipient "purse_keygroup" \ --hidden-recipient "purse_keygroup" \
--output "${1}" "${2}" --throw-keyids --comment "${comment}" \
--output "${1}" "${2}" 2>/dev/null
} }
read_pass () { read_pass () {
# Read a password from safe. # 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 while [[ -z "${username}" ]] ; do
if [[ -z "${2+x}" ]] ; then read -r -p " if [[ -z "${2+x}" ]] ; then read -r -p "
Username: " username Username: " username
else username="${2}" ; fi else username="${2}" ; fi
done done
if [[ "${encrypt_index}" = "true" ]] ; then if [[ -n "${encrypt_index}" ]] ; then prompt_key "index"
prompt_key "index" spath=$(decrypt "${safe_ix}" | \
spath=$(decrypt "${safeix}" | \
grep -F "${username}" | tail -1 | cut -d ":" -f2) || \ grep -F "${username}" | tail -1 | cut -d ":" -f2) || \
fail "Decryption failed" fail "Secret not available"
else else spath=$(grep -F "${username}" "${safe_ix}" | \
spath=$(grep -F "${username}" "${safeix}" | \
tail -1 | cut -d ":" -f2) tail -1 | cut -d ":" -f2)
fi fi
prompt_key "password" prompt_key "password"
if [[ -s "${spath}" ]] ; then if [[ -s "${spath}" ]] ; then
clip <(decrypt "${spath}" | head -1) || \ clip <(decrypt "${spath}" | head -1) || \
fail "Decryption failed" fail "Failed to decrypt ${spath}"
else fail "Secret not available" else fail "Secret not available"
fi fi
} }
@ -104,116 +116,114 @@ read_pass () {
prompt_key () { prompt_key () {
# Print a message if safe file exists. # 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}" printf "\n Touch key to access %s ...\n" "${1}"
fi fi
} }
gen_pass () { gen_pass () {
# Generate a password using GPG. # Generate a password from urandom.
if [[ -z "${3+x}" ]] ; then read -r -p " if [[ -z "${3+x}" ]] ; then read -r -p "
Password length (default: ${pass_len}): " length Password length (default: ${pass_len}): " length
else length="${3}" ; fi 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 fold -w "${pass_len}" | head -1
} }
write_pass () { write_pass () {
# Write a password and update the index. # 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}") clip <(printf '%s' "${userpass}")
fi fi
fpath="$(LC_LANG=C tr -dc '[:lower:]' < /dev/urandom | fold -w10 | head -1)"
spath="${safedir}/${fpath}"
printf '%s\n' "${userpass}" | \ printf '%s\n' "${userpass}" | \
encrypt "${spath}" - || \ encrypt "${spath}" - || \
fail "Failed to put ${spath}" fail "Failed saving ${spath}"
userpass=""
if [[ "${encrypt_index}" = "true" ]] ; then if [[ -n "${encrypt_index}" ]] ; then
prompt_key "index" prompt_key "index"
( if [[ -f "${safeix}" ]] ; then ( if [[ -f "${safe_ix}" ]] ; then
decrypt "${safeix}" || return ; fi decrypt "${safe_ix}" || return ; fi
printf "%s@%s:%s\n" "${username}" "${now}" "${spath}") | \ printf "%s@%s:%s\n" "${username}" "${now}" "${spath}") | \
encrypt "${safeix}.${now}" - || \ encrypt "${safe_ix}.${now}" - && \
fail "Failed to put ${safeix}.${now}" mv "${safe_ix}.${now}" "${safe_ix}" || \
mv "${safeix}.${now}" "${safeix}" fail "Failed saving ${safe_ix}.${now}"
else else
printf "%s@%s:%s\n" "${username}" "${now}" "${spath}" >> "${safeix}" printf "%s@%s:%s\n" \
"${username}" "${now}" "${spath}" >> "${safe_ix}"
fi fi
} }
list_entry () { list_entry () {
# Decrypt the index to list entries. # 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 if [[ -n "${encrypt_index}" ]] ; then prompt_key "index"
prompt_key "index" decrypt "${safe_ix}" || fail "${safe_ix} not available"
decrypt "${safeix}" || fail "Decryption failed" else printf "\n" ; cat "${safe_ix}"
else
printf "\n"
cat "${safeix}"
fi fi
} }
backup () { backup () {
# Archive index, safe and configuration. # Archive index, safe and configuration.
if [[ -f "${safeix}" && -d "${safedir}" ]] ; then if [[ ! -f ${safe_backup} ]] ; then
cp "${gpgconf}" "gpg.conf.${now}" if [[ -f "${safe_ix}" && -d "${safe_dir}" ]] ; then
tar --create --file "${backuptar}" \ cp "${gpg_conf}" "gpg.conf.${today}"
"${safeix}" "${safedir}" "gpg.conf.${now}" "${script}" tar cf "${safe_backup}" "${safe_dir}" "${safe_ix}" \
rm "gpg.conf.${now}" "${BASH_SOURCE}" "gpg.conf.${today}" && \
printf "\nArchived %s\n" "${safe_backup}"
rm -f "gpg.conf.${today}"
else fail "Nothing to archive" ; fi else fail "Nothing to archive" ; fi
else warn "${safe_backup} exists, skipping archive" ; fi
printf "\nArchived %s\n" "${backuptar}"
} }
clip () { 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" printf "\n"
shift while [[ "${clip_timeout}" -gt 0 ]] ; do
while [ $cb_timeout -gt 0 ] ; do printf "\r\033[K Password on %s! Clearing in %.d" \
printf "\r\033[KPassword on clipboard! Clearing in %.d" $((cb_timeout--)) "${clip_dest}" "$((clip_timeout--))" ; sleep 1
sleep 1
done done
printf "\r\033[K Clearing password from %s ..." "${clip_dest}"
printf "\n" if [[ "${clip_dest}" = "screen" ]] ; then clear
printf "" | ${copy} else printf "\n" ; printf "" | "${copy}" ; fi
} }
setup_keygroup() { setup_keygroup() {
# Configure GPG keygroup. # Configure one or more recipients.
purse_keygroup="group purse_keygroup =" purse_keygroup="group purse_keygroup ="
keyid="" keyid=""
recommend="$(${gpg} -K | grep "sec#" | \ recommend="$(${gpg} -K | grep "sec#" | \
awk -F "/" '{print $2}' | cut -c-18 | tr "\n" " ")" 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 while [[ -z "${keyid}" ]] ; do read -r -p "
Enter backup key IDs first, preferred key IDs last.
" "${recommend}"
while [[ -z "${keyid}" ]] ; do
read -r -p "
Key ID or Enter to continue: " keyid Key ID or Enter to continue: " keyid
if [[ -z "${keyid}" ]] ; then if [[ -z "${keyid}" ]] ; then
printf "%s\n" "$purse_keygroup" >> "${gpgconf}" printf "%s\n" "${purse_keygroup}" >> "${gpg_conf}"
break break
fi fi
purse_keygroup="${purse_keygroup} ${keyid}" purse_keygroup="${purse_keygroup} ${keyid}"
@ -224,18 +234,18 @@ setup_keygroup() {
new_entry () { new_entry () {
# Prompt for username and password. # Prompt for username and password.
username=""
while [[ -z "${username}" ]] ; do while [[ -z "${username}" ]] ; do
if [[ -z "${2+x}" ]] ; then read -r -p " if [[ -z "${2+x}" ]] ; then read -r -p "
Username: " username Username: " username
else username="${2}" ; fi else username="${2}" ; fi
done done
if [[ -z "${3+x}" ]] ; then get_pass " if [[ -z "${3+x}" ]] ; then
Password for \"${username}\" (Enter to generate): " get_pass "Password for \"${username}\" (Enter to generate): "
userpass="${password}" userpass="${password}"
fi fi
printf "\n"
if [[ -z "${password}" ]] ; then if [[ -z "${password}" ]] ; then
userpass=$(gen_pass "$@") userpass=$(gen_pass "$@")
fi fi
@ -245,82 +255,62 @@ print_help () {
# Print help text. # Print help text.
printf """ 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 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
Purse can be used interactively or by passing one of the following options:
* 'w' to write a password * 'w' to write a password
* 'r' to read a password * 'r' to read a password
* 'l' to list passwords * 'l' to list passwords
* 'b' to create an archive for backup * 'b' to create an archive for backup\n
Options can also be passed on the command line.\n
Example usage: * Create a 20-character password for userName:
./purse.sh w userName 20\n
* Generate a 30 character password for 'userName': * Read password for userName:
./purse.sh w userName 30 ./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:
* Copy the password for 'userName' to clipboard:
./purse.sh r userName
* List stored passwords and copy a specific version:
./purse.sh l ./purse.sh l
./purse.sh r userName@1574723625 ./purse.sh r userName@1574723625\n
* Create an archive for backup: * Create an archive for backup:
./purse.sh b ./purse.sh b\n
* Restore an archive from backup: * Restore an archive from backup:
tar xvf purse*tar""" 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 if [[ -z "${copy}" || ! -x "${copy}" ]] ; then
chmod -R 0700 "${safedir}" 2>/dev/null warn "Clipboard not available, passwords will print to screen/stdout!"
clip_dest="screen"
fi
username=""
password="" password=""
action="" action=""
if [[ -n "${1+x}" ]] ; then action="${1}" ; fi if [[ -n "${1+x}" ]] ; then action="${1}" ; fi
while [[ -z "${action}" ]] ; do while [[ -z "${action}" ]] ; do read -r -n 1 -p "
read -r -n 1 -p "
Read or Write (or Help for more options): " action Read or Write (or Help for more options): " action
printf "\n" printf "\n"
done done
if [[ "${action}" =~ ^([hH])$ ]] ; then if [[ "${action}" =~ ^([rR])$ ]] ; then read_pass "$@"
print_help
elif [[ "${action}" =~ ^([bB])$ ]] ; then
backup
elif [[ "${action}" =~ ^([lL])$ ]] ; then
list_entry
elif [[ "${action}" =~ ^([wW])$ ]] ; then elif [[ "${action}" =~ ^([wW])$ ]] ; then
purse_keygroup=$(grep "group purse_keygroup" "${gpgconf}") purse_keygroup="$(grep "group purse_keygroup" "${gpg_conf}")"
if [[ -z "${purse_keygroup}" ]] ; then if [[ -z "${purse_keygroup}" ]] ; then
setup_keygroup setup_keygroup
fi fi
printf "\n %s\n" "${purse_keygroup}" printf "\n %s\n" "${purse_keygroup}"
new_entry "$@" new_entry "$@"
write_pass write_pass
if [[ -n "${daily_backup}" ]] ; then backup ; fi
if [[ "${daily_backup}" = "true" ]] ; then elif [[ "${action}" =~ ^([lL])$ ]] ; then list_entry
if [[ ! -f ${backuptar} ]] ; then elif [[ "${action}" =~ ^([bB])$ ]] ; then backup
backup else print_help ; fi
fi
fi
else read_pass "$@" ; fi
chmod -R 0400 "${safeix}" "${safedir}" 2>/dev/null
tput setaf 2 ; printf "\nDone\n" ; tput sgr0 tput setaf 2 ; printf "\nDone\n" ; tput sgr0