Compare commits

...

22 Commits
1.0 ... master

Author SHA1 Message Date
drduh 8f09cf87e3
Merge pull request #7 from drduh/wip-26mar24
More config options, trap exits, stdout support
2024-03-27 17:12:09 +00:00
drduh abcb88a032 grammar 2024-03-27 10:11:45 -07:00
drduh c8ede9797a Encrypted index config option 2024-03-26 16:56:11 -07:00
drduh 1b990c96a6 Add chars and echo config options 2024-03-26 16:36:25 -07:00
drduh e6c3828504 Fix clip functionality 2024-03-26 15:57:33 -07:00
drduh 404d5402d1 Style touch-ups 2024-03-26 14:52:56 -07:00
drduh f429a52625 Safer archive backups 2024-03-26 14:32:02 -07:00
drduh 02b910b326 Add configuration options to README 2024-03-26 14:23:36 -07:00
drduh 80d501cba8 Update to pwd.sh parity 2024-03-26 14:18:57 -07:00
drduh 2c02ec96a3
Create FUNDING.yml 2024-03-10 22:38:49 +00:00
drduh 15bb2a9dc8
Merge pull request #6 from drduh/wip-10mar24
Version 3 beta
2024-03-10 22:23:51 +00:00
drduh 0ca2711773
Merge branch 'master' into wip-10mar24 2024-03-10 22:20:12 +00:00
drduh 84a98927c4 more formatting 2024-03-10 15:13:41 -07:00
drduh 1370d1ee13 Clean up readme 2024-03-10 15:09:41 -07:00
drduh 5cc6c81ed0 Version 3 beta 2024-03-10 14:59:33 -07:00
drduh 7e53a6b510
Merge pull request #5 from drduh/document-tr-bug
Document (fixed) tr bug
2022-12-26 15:46:04 -08:00
drduh 9df8f4c58c Document (fixed) tr bug 2022-12-26 15:45:39 -08:00
drduh 6976cff500
Merge pull request #3 from DrearyLisper/master
Add LANG=C to get tr working.
2022-08-21 11:17:15 -07:00
Dreary Lisper acca8834a4
Add LANG=C to get tr working. 2022-01-04 18:45:43 +00:00
drduh 7074a8bf4c Include script in backup archive 2020-05-25 14:58:56 -07:00
drduh 883e68a95f Use keygroup, make encrypted index optional 2020-05-25 14:22:21 -07:00
drduh 8d9ca6c14d Version 2 beta, see release notes in README. 2019-11-28 15:18:48 -08:00
5 changed files with 286 additions and 180 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
github: [drduh]

3
.gitignore vendored Normal file
View File

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

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018-2019 drduh
Copyright (c) 2018 drduh
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,72 +1,88 @@
# Purse
![screencast gif](https://user-images.githubusercontent.com/12475110/40880505-3834ce1c-6667-11e8-89d0-6961886842c6.gif)
Purse is a fork of [drduh/pwd.sh](https://github.com/drduh/pwd.sh).
Both programs are shell scripts which use [GPG](https://www.gnupg.org/) to manage passwords in an encrypted text file. Purse uses asymmetric (public-key) authentication, while pwd.sh uses 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 (GPG) and safely handle passwords (never saving plaintext to disk), Purse eliminates the need to remember and use a master password - just plug in a YubiKey, enter the PIN, then touch it to decrypt the password safe to stdout.
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.
By using Purse with YubiKey, the risk of master password phishing and keylogging is eliminated - only physical possession of the key AND knowledge of the PIN can unlock the password safe.
# Install
# Installation
This script requires a GnuPG identity - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up.
This script requires a GPG identity - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up.
To install Purse:
For the latest version, clone the repository or download the script directly:
```console
$ git clone https://github.com/drduh/Purse
git clone https://github.com/drduh/Purse
wget https://github.com/drduh/Purse/blob/master/purse.sh
```
Edit `purse.sh` to specify your GPG key ID.
Versioned [Releases](https://github.com/drduh/Purse/releases) are also available.
# Use
`cd Purse` and run the script interactively using `./purse.sh`
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 `d` to delete a password.
* 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
Examples:
Options can also be passed on the command line.
Create 30-character password for `gmail`:
Create a 20-character password for `userName`:
```console
$ ./purse.sh w gmail 30
./purse.sh w userName 20
```
Append `q` to create a password without displaying it.
Read password for `user@github`:
Read password for `userName`:
```console
$ ./purse.sh r user@github
./purse.sh r userName
```
Delete password for `reddit`:
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 d reddit
./purse.sh l
./purse.sh r userName@1574723600
```
Copy password for `github` to clipboard (substitute `pbcopy` on macOS):
Create an archive for backup:
```console
$ ./purse.sh r github | cut -f 1 -d ' ' | awk 'NR==4{print $1}' | xclip
./purse.sh b
```
This script and encrypted `purse.enc` file can be publicly shared between trusted computers. For additional privacy, the recipient key ID is **not** included in GPG metadata.
Restore an archive from backup:
See [drduh/config/gpg.conf](https://github.com/drduh/config/blob/master/gpg.conf) for additional GPG options.
```console
tar xvf purse*tar
```
# Similar software
# Configure
* [drduh/pwd.sh](https://github.com/drduh/pwd.sh)
* [bndw/pick: command-line password manager for macOS and Linux](https://github.com/bndw/pick)
* [Pass: the standard unix password manager](https://www.passwordstore.org/)
* [anders/pwgen: generate passwords using OS X Security framework](https://github.com/anders/pwgen)
* [caodonnell/passman.sh: a pwd.sh fork](https://github.com/caodonnell/passman.sh)
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:
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.

370
purse.sh
View File

@ -1,230 +1,316 @@
#!/usr/bin/env bash
# https://github.com/drduh/Purse
# https://github.com/drduh/Purse/blob/master/purse.sh
#set -x # uncomment to debug
set -o errtrace
set -o nounset
set -o pipefail
umask 077
export LC_ALL="C"
filter="$(command -v grep) -v -E"
now="$(date +%s)"
today="$(date +%F)"
copy="$(command -v xclip || command -v pbcopy)"
gpg="$(command -v gpg || command -v gpg2)"
safe="${PURSE_SAFE:=purse.enc}"
keyid="0xFF3E7D88647EBCDB"
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.
printf "\n\n"
tput setaf 1 1 1 ; echo "Error: ${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
}
decrypt () {
# Decrypt with authorized GPG key.
# Decrypt with GPG.
cat "${1}" | ${gpg} --armor --batch --decrypt 2>/dev/null
cat "${1}" | \
${gpg} --armor --batch --decrypt 2>/dev/null
}
encrypt () {
# Encrypt to a recipient.
# Encrypt to a group of hidden recipients.
${gpg} --encrypt --armor --batch --yes --throw-keyids \
--recipient ${keyid} --output "${1}" "${2}"
${gpg} --encrypt --armor --batch --yes \
--hidden-recipient "purse_keygroup" \
--throw-keyids --comment "${comment}" \
--output "${1}" "${2}" 2>/dev/null
}
read_pass () {
# Read a password from safe.
if [[ ! -s ${safe} ]] ; then fail "${safe} not found" ; fi
if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi
if [[ -z "${2+x}" ]] ; then read -r -p "
Username (Enter for all): " username
else
username="${2}"
while [[ -z "${username}" ]] ; do
if [[ -z "${2+x}" ]] ; then read -r -p "
Username: " username
else username="${2}" ; fi
done
if [[ -n "${encrypt_index}" ]] ; then prompt_key "index"
spath=$(decrypt "${safe_ix}" | \
grep -F "${username}" | tail -1 | cut -d ":" -f2) || \
fail "Secret not available"
else spath=$(grep -F "${username}" "${safe_ix}" | \
tail -1 | cut -d ":" -f2)
fi
if [[ -z "${username}" || "${username}" == "all" ]] ; then username="" ; fi
prompt_key
decrypt ${safe} | grep -F " ${username}" || fail "Decryption failed"
prompt_key "password"
if [[ -s "${spath}" ]] ; then
clip <(decrypt "${spath}" | head -1) || \
fail "Failed to decrypt ${spath}"
else fail "Secret not available"
fi
}
prompt_key () {
# Print a message when key touch is needed.
# Print a message if safe file exists.
if [[ -f "${safe}" ]] ; then
printf "\n Touch key to decrypt safe ...\n\n"
if [[ -f "${safe_ix}" ]] ; then
printf "\n Touch key to access %s ...\n" "${1}"
fi
}
gen_pass () {
# Generate a password.
# Generate a password from urandom.
len=20
max=80
if [[ -z "${3+x}" ]] ; then read -r -p "
Password length (default: ${pass_len}): " length
else length="${3}" ; fi
if [[ -z "${3+x}" ]] ; then read -p "
Password length (default: ${len}, max: ${max}): " length
else
length="${3}"
if [[ "${length}" =~ ^[0-9]+$ ]] ; then
pass_len="${length}"
fi
if [[ ${length} =~ ^[0-9]+$ ]] ; then len=${length} ; fi
# base64: 4 characters for every 3 bytes
${gpg} --armor --gen-random 0 "$((${max} * 3/4))" | cut -c -"${len}"
}
write_pass () {
# Write a password in safe.
# If no password (delete action), clear the entry by writing an empty line.
if [[ -z "${userpass+x}" ]] ; then
entry=" "
else
entry="${userpass} ${username}"
fi
prompt_key
# If safe exists, decrypt it and filter out username, or bail on error.
# If successful, append entry, or blank line.
# Filter blank lines and previous timestamp, append fresh timestamp.
# Finally, encrypt it all to a new safe file, or fail.
# If successful, update to new safe file.
( if [[ -f "${safe}" ]] ; then
decrypt ${safe} | ${filter} " ${username}$" || return
fi ; \
echo "${entry}") | \
(${filter} "^[[:space:]]*$|^mtime:[[:digit:]]+$";echo mtime:$(date +%s)) | \
encrypt ${safe}.new - || fail "Write to safe failed"
mv ${safe}{.new,}
tr -dc "${pass_chars}" < /dev/urandom | \
fold -w "${pass_len}" | head -1
}
write_pass () {
# Write a password and update the index.
new_entry () {
# Prompt for new username and/or password.
spath="${safe_dir}/$(tr -dc "[:lower:]" < /dev/urandom | \
fold -w10 | head -1)"
if [[ -z "${2+x}" ]] ; then read -r -p "
Username: " username
else
username="${2}"
if [[ -n "${pass_copy}" ]] ; then
clip <(printf '%s' "${userpass}")
fi
if [[ -z "${3+x}" ]] ; then get_pass "
Password for \"${username}\" (Enter to generate): "
printf '%s\n' "${userpass}" | \
encrypt "${spath}" - || \
fail "Failed saving ${spath}"
if [[ -n "${encrypt_index}" ]] ; then
prompt_key "index"
( if [[ -f "${safe_ix}" ]] ; then
decrypt "${safe_ix}" || return ; fi
printf "%s@%s:%s\n" "${username}" "${now}" "${spath}") | \
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}" >> "${safe_ix}"
fi
}
list_entry () {
# Decrypt the index to list entries.
if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi
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 ${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 or stdout and clear after timeout.
if [[ "${clip_dest}" = "screen" ]] ; then
printf '\n%s\n' "$(cat ${1})"
else "${copy}" < "${1}" ; fi
printf "\n"
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}"
if [[ "${clip_dest}" = "screen" ]] ; then clear
else printf "\n" ; printf "" | "${copy}" ; fi
}
setup_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 keygroup ...\n
Found recommended key IDs: %s\n
Enter one or more key IDs, preferred one last\n" "${recommend}"
while [[ -z "${keyid}" ]] ; do read -r -p "
Key ID or Enter to continue: " keyid
if [[ -z "${keyid}" ]] ; then
printf "%s\n" "${purse_keygroup}" >> "${gpg_conf}"
break
fi
purse_keygroup="${purse_keygroup} ${keyid}"
keyid=""
done
}
new_entry () {
# Prompt for username and password.
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): "
userpass="${password}"
fi
printf "\n"
if [[ -z "${password}" ]] ; then userpass=$(gen_pass "$@")
if [[ -z "${4+x}" || ! "${4}" =~ ^([qQ])$ ]] ; then
printf "\n Password: ${userpass}\n"
fi
if [[ -z "${password}" ]] ; then
userpass=$(gen_pass "$@")
fi
}
print_help () {
# Print help text.
echo "
Purse is a password manager shell script using GnuPG asymmetric encryption. It is recommended for use with Yubikey or similar hardware token. Purse can be used interactively or with one of the following options:
* 'r' to read a password
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.\n
Purse can be used interactively or by passing one of the following options:\n
* 'w' to write a password
* 'd' to delete a password
* 'h' to print this help text
A username, password length and 'q' options can also be used.
Examples:
* Read all passwords:
./purse.sh r all
* Write a password for 'github':
./purse.sh w github
* Generate a 50 character password for 'github' and write:
./purse.sh w github 50
* Generate a password and write without displaying it:
./purse.sh w github 50 q
* Delete password for 'mail':
./purse.sh d mail
A password cannot be supplied as an argument, nor is used as one in the script, to prevent it from appearing in process listing or logs."
* 'r' to read a password
* 'l' to list passwords
* '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 [[ ! -f "${gpg_conf}" ]] ; then fail "GnuPG config is not available" ; fi
if [[ ! -d "${safe_dir}" ]] ; then mkdir -p "${safe_dir}" ; fi
chmod -R 0700 "${safe_dir}" "${safe_ix}" 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 -n 1 -p "
Read, Write, or Delete password (or Help): " action
if [[ -n "${1+x}" ]] ; then action="${1}" ; fi
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
if [[ "${action}" =~ ^([rR])$ ]] ; then read_pass "$@"
elif [[ "${action}" =~ ^([wW])$ ]] ; then
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
elif [[ "${action}" =~ ^([dD])$ ]] ; then
if [[ -z "${2+x}" ]] ; then read -p "
Username: " username
else
username="${2}"
fi
write_pass
else
read_pass "$@"
fi
if [[ -n "${daily_backup}" ]] ; then backup ; fi
elif [[ "${action}" =~ ^([lL])$ ]] ; then list_entry
elif [[ "${action}" =~ ^([bB])$ ]] ; then backup
else print_help ; fi
printf "\n" ; tput setaf 2 2 2 ; echo "Done" ; tput sgr0
tput setaf 2 ; printf "\nDone\n" ; tput sgr0