Skip to content

Commit

Permalink
Merge pull request #6 from drduh/wip-10mar24
Browse files Browse the repository at this point in the history
Version 3 beta
  • Loading branch information
drduh authored Mar 10, 2024
2 parents 7e53a6b + 0ca2711 commit 15bb2a9
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 109 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
purse.*.tar
purse.index
safe/
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
The MIT License (MIT)

Copyright (c) 2018-2020 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
Expand Down
86 changes: 23 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,31 @@

Purse is a fork of [drduh/pwd.sh](https://github.com/drduh/pwd.sh).

Both programs are Bash shell scripts which use [GPG](https://www.gnupg.org/) to manage passwords and other secrets in encrypted text files. 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 a password to clipboard.

By using Purse with YubiKey, the risk of master password theft or keylogging is eliminated - only physical possession of the Yubikey AND knowledge of the PIN can unlock the encrypted index and password files.
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.

# Release notes

## Version 2b1 (2020)

Minor update to the second release. Currently in beta testing. Compatible on Linux, OpenBSD, macOS.

Known Issues:

* Newer versions of macOS error with `tr: Illegal byte sequence` - see [issue #4](https://github.com/drduh/Purse/issues/4)


Changelist:

* Purse now uses a GPG keygroup to encrypt secrets to multiple recipients for improved reliability. The program will prompt for key IDs to define the keygroup; a single key ID can still be used.
* Encrypted index is now optional and off by default, allowing a single touch to encrypt and decrypt secrets instead of two.
* GPG configuration file is now included in Purse backup archives.

## Version 2b (2019)

The second release of purse.sh features several security and reliability improvements, and is an optional upgrade. Currently in beta testing. Compatible on Linux, OpenBSD, macOS.
See [Releases](https://github.com/drduh/Purse/releases)

Known Issues:

* Read actions now require two Yubikey touches, if touch to decrypt is enabled - once for the index and twice for the encrypted password file.

Changelist:

* Passwords are now encrypted as individual files, rather than all encrypted as a single flat file.
* Individual password filenames are random, mapped to usernames in an encrypted index file.
* Index and password files are now "immutable" using chmod while purse.sh is not running.
* Read passwords are now copied to clipboard and cleared after a timeout, instead of printed to stdout.
* Use printf instead of echo for improved portability.
* New option: list passwords in the index.
* New option: create tar archive for backup.
* Removed option: delete password; the index is now a permanent ledger.
* Removed option: read all passwords; no use case for having a single command.
* Removed option: suppress generated password output; should be read from safe to verify save.
# Use

## Version 1 (2018)
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.

The original release which has been available for general use and review since June 2018 (forked from pwd.sh which dates to 2015). There are no known bugs nor security vulnerabilities identified in this stable version of purse.sh. Compatible on Linux, OpenBSD, macOS.
Clone the repository:

# Use
```console
git clone https://github.com/drduh/Purse
```

This script requires a GPG identity - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up. Multiple identities stored on several YubiKeys are recommended for reliability.
Or download the script directly:

```console
$ git clone https://github.com/drduh/Purse
wget https://github.com/drduh/Purse/blob/master/purse.sh
```

(Version 2b and older) Set your GPG key ID with `export PURSE_KEYID=0xFF3E7D88647EBCDB` or by editing `purse.sh`.

`cd purse.sh` and 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
* Type `r` to read a password
Expand All @@ -72,46 +38,40 @@ Options can also be passed on the command line.

Example usage:

Create a 30-character password for `userName`:
Create a 20-character password for `userName`:

```console
$ ./purse.sh w userName 30
./purse.sh w userName 20
```

Read password for `userName`:

```console
$ ./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 previous version of a password:
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:

```console
$ ./purse.sh l
./purse.sh l

$ ./purse.sh r userName@1574723600
./purse.sh r userName@1574723600
```

Create an archive for backup:

```console
$ ./purse.sh b
./purse.sh b
```

Restore an archive from backup:

```console
$ tar xvf purse*tar
tar xvf purse*tar
```

The backup contains only encrypted passwords and can be publicly shared for use on trusted computers. For additional privacy, the recipient key ID is **not** included in GPG metadata (`throw-keyids` option). The password index file can also be encrypted by changing the `encrypt_index` variable to `true` in the script.

See [drduh/config/gpg.conf](https://github.com/drduh/config/blob/master/gpg.conf) for additional GPG configuration options.
**Note** For additional privacy, the recipient key ID is **not** included in metadata (`throw-keyids` option).

# Similar software
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.

* [drduh/pwd.sh](https://github.com/drduh/pwd.sh)
* [zx2c4/password-store](https://github.com/zx2c4/password-store)
* [caodonnell/passman.sh: a pwd.sh fork](https://github.com/caodonnell/passman.sh)
* [bndw/pick: command-line password manager for macOS and Linux](https://github.com/bndw/pick)
* [anders/pwgen: generate passwords using OS X Security framework](https://github.com/anders/pwgen)
See [config/gpg.conf](https://github.com/drduh/config/blob/master/gpg.conf) for additional configuration options.
108 changes: 63 additions & 45 deletions purse.sh
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
#!/usr/bin/env bash
# https://github.com/drduh/Purse/blob/master/purse.sh

set -o errtrace
set -o nounset
set -o pipefail

#set -x # uncomment to debug
#set -x # uncomment to debug

umask 077

encrypt_index="false"
now=$(date +%s)
copy="$(command -v xclip || command -v pbcopy)"
gpg="$(command -v gpg || command -v gpg2)"
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}"
script="$(basename $BASH_SOURCE)"
timeout=10

now="$(date +%s)"
copy="$(command -v xclip || command -v pbcopy)"
gpg="$(command -v gpg || command -v gpg2)"
script="$(basename "${BASH_SOURCE}")"

fail () {
# Print an error message and exit.

tput setaf 1 1 1 ; printf "\nError: %s\n" "${1}" ; tput sgr0
tput setaf 1 ; printf "\nError: %s\n" "${1}" ; tput sgr0
exit 1
}

Expand Down Expand Up @@ -53,7 +57,8 @@ get_pass () {
decrypt () {
# Decrypt with GPG.

cat "${1}" | ${gpg} --armor --batch --decrypt 2>/dev/null
cat "${1}" | \
${gpg} --armor --batch --decrypt 2>/dev/null
}

encrypt () {
Expand All @@ -80,17 +85,20 @@ read_pass () {
prompt_key "index"

spath=$(decrypt "${safeix}" | \
grep -F "${username}" | tail -n1 | cut -d ":" -f2) || \
grep -F "${username}" | tail -1 | cut -d ":" -f2) || \
fail "Decryption failed"
else
spath=$(grep -F "${username}" "${safeix}" | \
tail -n1 | cut -d ":" -f2)
tail -1 | cut -d ":" -f2)
fi

prompt_key "password"

clip <(decrypt "${spath}" | head -n1) || \
fail "Decryption failed"
if [[ -s "${spath}" ]] ; then
clip <(decrypt "${spath}" | head -1) || \
fail "Decryption failed"
else fail "Secret not available"
fi
}

prompt_key () {
Expand All @@ -104,28 +112,30 @@ prompt_key () {
gen_pass () {
# Generate a password using GPG.

len=20
max=80

if [[ -z "${3+x}" ]] ; then read -r -p "
Password length (default: ${len}, max: ${max}): " length
Password length (default: ${pass_len}): " length
else length="${3}" ; fi

if [[ ${length} =~ ^[0-9]+$ ]] ; then len=${length} ; fi
if [[ ${length} =~ ^[0-9]+$ ]] ; then pass_len=${length} ; fi

# base64: 4 characters for every 3 bytes
${gpg} --armor --gen-random 0 "$((max * 3 / 4))" | cut -c -"${len}"
LC_LANG=C tr -dc "${pass_chars}" < /dev/urandom | \
fold -w "${pass_len}" | head -1
}

write_pass () {
# Write a password and update index file.
# Write a password and update the index.

if [[ "${pass_copy}" = "true" ]] ; then
clip <(printf '%s' "${userpass}")
fi

fpath=$(LC_LANG=C tr -dc "[:lower:]" < /dev/urandom | fold -w8 | head -n1)
spath=${safedir}/${fpath}
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=""

if [[ "${encrypt_index}" = "true" ]] ; then
prompt_key "index"
Expand All @@ -139,7 +149,6 @@ write_pass () {
else
printf "%s@%s:%s\n" "${username}" "${now}" "${spath}" >> "${safeix}"
fi

}

list_entry () {
Expand All @@ -151,21 +160,22 @@ list_entry () {
prompt_key "index"
decrypt "${safeix}" || fail "Decryption failed"
else
printf "\n"
cat "${safeix}"
fi
}

backup () {
# Create an archive for backup.
# Archive index, safe and configuration.

if [[ -f "${safeix}" ]] ; then
if [[ -f "${safeix}" && -d "${safedir}" ]] ; then
cp "${gpgconf}" "gpg.conf.${now}"
tar cfv "${backuptar}" \
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}"
printf "\nArchived %s\n" "${backuptar}"
}

clip () {
Expand All @@ -175,17 +185,17 @@ clip () {

printf "\n"
shift
while [ $timeout -gt 0 ] ; do
printf "\r\033[KPassword on clipboard! Clearing in %.d" $((timeout--))
while [ $cb_timeout -gt 0 ] ; do
printf "\r\033[KPassword on clipboard! Clearing in %.d" $((cb_timeout--))
sleep 1
done

printf "\n"
printf "" | ${copy}
}


setup_keygroup() {
# Configure GPG keygroup setting.
# Configure GPG keygroup.

purse_keygroup="group purse_keygroup ="
keyid=""
Expand All @@ -212,7 +222,7 @@ setup_keygroup() {
}

new_entry () {
# Prompt for new username and/or password.
# Prompt for username and password.

username=""
while [[ -z "${username}" ]] ; do
Expand All @@ -226,7 +236,9 @@ new_entry () {
userpass="${password}"
fi

if [[ -z "${password}" ]] ; then userpass=$(gen_pass "$@") ; fi
if [[ -z "${password}" ]] ; then
userpass=$(gen_pass "$@")
fi
}

print_help () {
Expand All @@ -250,7 +262,7 @@ print_help () {
* Copy the password for 'userName' to clipboard:
./purse.sh r userName
* List stored passwords and copy a previous version:
* List stored passwords and copy a specific version:
./purse.sh l
./purse.sh r userName@1574723625
Expand All @@ -263,21 +275,21 @@ print_help () {

if [[ -z ${gpg} && ! -x ${gpg} ]] ; then fail "GnuPG is not available" ; fi

if [[ ! -f ${gpgconf} ]] ; then fail "GnuPG config is not available" ; fi

if [[ -z ${copy} && ! -x ${copy} ]] ; then fail "Clipboard is not available" ; fi

if [[ ! -d ${safedir} ]] ; then mkdir -p ${safedir} ; fi
if [[ ! -f ${gpgconf} ]] ; then fail "GnuPG config is not available" ; fi

if [[ ! -d "${safedir}" ]] ; then mkdir -p "${safedir}" ; fi

chmod -R 0600 ${safeix} 2>/dev/null
chmod -R 0700 ${safedir} 2>/dev/null
chmod -R 0600 "${safeix}" 2>/dev/null
chmod -R 0700 "${safedir}" 2>/dev/null

password=""
action=""
if [[ -n "${1+x}" ]] ; then action="${1}" ; fi

while [[ -z "${action}" ]] ; do
read -n 1 -p "
read -r -n 1 -p "
Read or Write (or Help for more options): " action
printf "\n"
done
Expand All @@ -301,8 +313,14 @@ elif [[ "${action}" =~ ^([wW])$ ]] ; then
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
chmod -R 0400 "${safeix}" "${safedir}" 2>/dev/null

tput setaf 2 2 2 ; printf "\nDone\n" ; tput sgr0
tput setaf 2 ; printf "\nDone\n" ; tput sgr0

0 comments on commit 15bb2a9

Please sign in to comment.