├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md └── purse.sh /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [drduh] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | purse.*.tar 2 | purse.index* 3 | safe/ 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 drduh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Purse is a Bash shell script based on [drduh/pwd.sh](https://github.com/drduh/pwd.sh). 2 | 3 | Both programs use [GnuPG](https://www.gnupg.org/) to manage secrets in encrypted text files. Purse is based on asymmetric (public-key) authentication, while [pwd.sh](https://github.com/drduh/pwd.sh) is based on symmetric (passphrase-based) authentication. 4 | 5 | Purse eliminates the need for a passphrase: plug in the YubiKey, enter PIN and touch it to access secrets. 6 | 7 | > [!IMPORTANT] 8 | > A GnuPG identity is required to use Purse - see [drduh/YubiKey-Guide](https://github.com/drduh/YubiKey-Guide) to set one up. 9 | 10 | # Install 11 | 12 | Purse is available for download from [Releases](https://github.com/drduh/Purse/releases), or directly from GitHub: 13 | 14 | ```console 15 | wget https://github.com/drduh/Purse/blob/master/purse.sh 16 | ``` 17 | 18 | # Use 19 | 20 | Run the script interactively using `./purse.sh` or symlink to a directory in `PATH`: 21 | 22 | - `w` to create a secret 23 | - `r` to access a secret 24 | - `l` to list all secrets 25 | - `b` to create a backup archive 26 | - `h` to print the help text 27 | 28 | Options can also be passed on the command line. 29 | 30 | Create a 20-character password for `userName`: 31 | 32 | ```console 33 | ./purse.sh w userName 20 34 | ``` 35 | 36 | Read password for `userName`: 37 | 38 | ```console 39 | ./purse.sh r userName 40 | ``` 41 | 42 | 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: 43 | 44 | ```console 45 | ./purse.sh l 46 | 47 | ./purse.sh r userName@1574723600 48 | ``` 49 | 50 | Create an archive for backup: 51 | 52 | ```console 53 | ./purse.sh b 54 | ``` 55 | 56 | Restore an archive from backup: 57 | 58 | ```console 59 | tar xvf purse*tar 60 | ``` 61 | 62 | # Configure 63 | 64 | See [config/gpg.conf](https://github.com/drduh/config/blob/main/gpg.conf) for recommended GnuPG options. 65 | 66 | 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/main/zshrc) file: 67 | 68 | Variable | Description | Default | Available options 69 | ---: | :---: | :---: | :--- 70 | `PURSE_CLIP` | clipboard to use | `xclip` | `pbcopy` on macOS 71 | `PURSE_CLIP_ARGS` | arguments to pass to clipboard command | unset (disabled) | `-i -selection clipboard` to use primary (control-v) clipboard with xclip 72 | `PURSE_TIME` | seconds to clear password from clipboard/screen | `10` | any valid integer 73 | `PURSE_LEN` | default generated password length | `14` | any valid integer 74 | `PURSE_COPY` | copy password to clipboard before write | unset (disabled) | `1` or `true` to enable 75 | `PURSE_DAILY` | create daily backup archive on write | unset (disabled) | `1` or `true` to enable 76 | `PURSE_ENCIX` | encrypt index for additional privacy; 2 YubiKey touches will be required for separate decryption operations | unset (disabled) | `1` or `true` to enable 77 | `PURSE_COMMENT` | **unencrypted** comment to include in index and safe files | unset | any valid string 78 | `PURSE_CHARS` | character set for passwords | `[:alnum:]!?@#$%^&*();:+=` | any valid characters 79 | `PURSE_DEST` | password output destination, will set to `screen` without clipboard | `clipboard` | `clipboard` or `screen` 80 | `PURSE_ECHO` | character used to echo password input | `*` | any valid character 81 | `PURSE_SAFE` | safe directory name | `safe` | any valid string 82 | `PURSE_INDEX` | index file name | `purse.index` | any valid string 83 | `PURSE_BACKUP` | backup archive file name | `purse.$hostname.$today.tar` | any valid string 84 | 85 | > [!NOTE] 86 | > For privacy, the recipient key ID is **not** included in metadata (using the GnuPG `throw-keyids` option). 87 | -------------------------------------------------------------------------------- /purse.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # https://github.com/drduh/Purse/blob/master/purse.sh 3 | #set -x # uncomment to debug 4 | set -o errtrace 5 | set -o nounset 6 | set -o pipefail 7 | umask 077 8 | export LC_ALL="C" 9 | 10 | now="$(date +%s)" 11 | today="$(date +%F)" 12 | gpg="$(command -v gpg || command -v gpg2)" 13 | gpg_conf="${HOME}/.gnupg/gpg.conf" 14 | 15 | clip="${PURSE_CLIP:=xclip}" # clipboard, 'pbcopy' on macOS 16 | clip_args=${PURSE_CLIP_ARGS:=} # args to pass to copy command 17 | clip_dest="${PURSE_DEST:=clipboard}" # set to 'screen' to print to stdout 18 | clip_timeout="${PURSE_TIME:=10}" # seconds to clear clipboard/screen 19 | comment="${PURSE_COMMENT:=}" # *unencrypted* comment in files 20 | daily_backup="${PURSE_DAILY:=}" # daily backup archive on write 21 | encrypt_index="${PURSE_ENCIX:=}" # also keep index encrypted 22 | pass_copy="${PURSE_COPY:=}" # copy password before write 23 | pass_echo="${PURSE_ECHO:=*}" # show "*" when typing passwords 24 | pass_len="${PURSE_LEN:=14}" # default password length 25 | safe_dir="${PURSE_SAFE:=safe}" # safe directory name 26 | safe_ix="${PURSE_INDEX:=purse.index}" # index file name 27 | safe_backup="${PURSE_BACKUP:=purse.$(hostname).${today}.tar}" 28 | pass_chars="${PURSE_CHARS:='[:alnum:]!?@#$%^&*();:+='}" 29 | 30 | trap cleanup EXIT INT TERM 31 | cleanup () { 32 | # "Lock" files on trapped exits. 33 | 34 | ret=$? 35 | chmod -R 0000 "${safe_dir}" "${safe_ix}" 2>/dev/null 36 | exit ${ret} 37 | } 38 | 39 | fail () { 40 | # Print an error in red and exit. 41 | 42 | tput setaf 1 ; printf "\nERROR: %s\n" "${1}" ; tput sgr0 43 | exit 1 44 | } 45 | 46 | warn () { 47 | # Print a warning in yellow. 48 | 49 | tput setaf 3 ; printf "\nWARNING: %s\n" "${1}" ; tput sgr0 50 | } 51 | 52 | setup_keygroup() { 53 | # Configure one or more recipients. 54 | 55 | purse_keygroup="group purse_keygroup =" 56 | keyid="" 57 | recommend="$(${gpg} -K | grep "sec#" | \ 58 | awk -F "/" '{print $2}' | cut -c-18 | tr "\n" " ")" 59 | 60 | printf "\n Setting up keygroup ...\n 61 | Found recommended key IDs: %s\n 62 | Enter one or more key IDs, preferred one last\n" "${recommend}" 63 | 64 | while [[ -z "${keyid}" ]] ; do read -r -p " 65 | Key ID or Enter to continue: " keyid 66 | if [[ -z "${keyid}" ]] ; then 67 | printf "%s\n" "${purse_keygroup}" >> "${gpg_conf}" 68 | break 69 | fi 70 | purse_keygroup="${purse_keygroup} ${keyid}" 71 | keyid="" 72 | done 73 | } 74 | 75 | get_pass () { 76 | # Prompt for a password. 77 | 78 | password="" 79 | prompt=" ${1}" 80 | printf "\n" 81 | 82 | while IFS= read -p "${prompt}" -r -s -n 1 char ; do 83 | if [[ ${char} == $'\0' ]] ; then break 84 | elif [[ ${char} == $'\177' ]] ; then 85 | if [[ -z "${password}" ]] ; then prompt="" 86 | else 87 | prompt=$'\b \b' 88 | password="${password%?}" 89 | fi 90 | else 91 | prompt="${pass_echo}" 92 | password+="${char}" 93 | fi 94 | done 95 | } 96 | 97 | decrypt () { 98 | # Decrypt with GPG. 99 | 100 | cat "${1}" | \ 101 | ${gpg} --armor --batch --decrypt 2>/dev/null 102 | } 103 | 104 | encrypt () { 105 | # Encrypt to a group of hidden recipients. 106 | 107 | ${gpg} --encrypt --armor --batch --yes \ 108 | --hidden-recipient "purse_keygroup" \ 109 | --throw-keyids --comment "${comment}" \ 110 | --output "${1}" "${2}" 2>/dev/null 111 | } 112 | 113 | read_pass () { 114 | # Read a password from safe. 115 | 116 | if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi 117 | 118 | while [[ -z "${username}" ]] ; do 119 | if [[ -z "${2+x}" ]] ; then read -r -p " 120 | Username: " username 121 | else username="${2}" ; fi 122 | done 123 | 124 | if [[ -n "${encrypt_index}" ]] ; then prompt_key "index" 125 | spath=$(decrypt "${safe_ix}" | \ 126 | grep -F "${username}" | tail -1 | cut -d ":" -f2) || \ 127 | fail "Secret not available" 128 | else spath=$(grep -F "${username}" "${safe_ix}" | \ 129 | tail -1 | cut -d ":" -f2) 130 | fi 131 | 132 | if [[ ! -s "${spath}" ]] ; then 133 | fail "Secret not available" ; fi 134 | 135 | prompt_key "password" 136 | emit_pass <(decrypt "${spath}" | head -1) || \ 137 | fail "Failed to decrypt ${spath}" 138 | } 139 | 140 | prompt_key () { 141 | # Print a message if safe file exists. 142 | 143 | if [[ -f "${safe_ix}" ]] ; then 144 | printf "\n Touch key to access %s ...\n" "${1}" ; fi 145 | } 146 | 147 | generate_pass () { 148 | # Generate a password from urandom. 149 | 150 | if [[ -z "${3+x}" ]] ; then read -r -p " 151 | Password length (default: ${pass_len}): " length 152 | else length="${3}" ; fi 153 | 154 | if [[ "${length}" =~ ^[0-9]+$ ]] ; then 155 | pass_len="${length}" 156 | fi 157 | 158 | tr -dc "${pass_chars}" < /dev/urandom | \ 159 | fold -w "${pass_len}" | head -1 160 | } 161 | 162 | generate_user () { 163 | # Generate a username. 164 | 165 | printf "%s%s\n" \ 166 | "$(awk 'length > 2 && length < 12 {print(tolower($0))}' \ 167 | /usr/share/dict/words | grep -v "'" | sort -R | head -n2 | \ 168 | tr "\n" "_" | iconv -f utf-8 -t ascii//TRANSLIT)" \ 169 | "$(tr -dc "[:digit:]" < /dev/urandom | fold -w 4 | head -1)" 170 | } 171 | 172 | write_pass () { 173 | # Write a password and update the index. 174 | 175 | spath="${safe_dir}/$(tr -dc "[:lower:]" < /dev/urandom | \ 176 | fold -w10 | head -1)" 177 | 178 | if [[ -n "${pass_copy}" ]] ; then 179 | emit_pass <(printf '%s' "${userpass}") ; fi 180 | 181 | printf '%s\n' "${userpass}" | encrypt "${spath}" - || \ 182 | fail "Failed saving ${spath}" 183 | 184 | if [[ -n "${encrypt_index}" ]] ; then 185 | prompt_key "index" 186 | 187 | ( if [[ -f "${safe_ix}" ]] ; then 188 | decrypt "${safe_ix}" || return ; fi 189 | printf "%s@%s:%s\n" "${username}" "${now}" "${spath}") | \ 190 | encrypt "${safe_ix}.${now}" - && \ 191 | mv "${safe_ix}.${now}" "${safe_ix}" || \ 192 | fail "Failed saving ${safe_ix}.${now}" 193 | else 194 | printf "%s@%s:%s\n" \ 195 | "${username}" "${now}" "${spath}" >> "${safe_ix}" 196 | fi 197 | } 198 | 199 | list_entry () { 200 | # Decrypt the index to list entries. 201 | 202 | if [[ ! -s "${safe_ix}" ]] ; then fail "${safe_ix} not found" ; fi 203 | 204 | if [[ -n "${encrypt_index}" ]] ; then prompt_key "index" 205 | decrypt "${safe_ix}" || fail "${safe_ix} not available" 206 | else printf "\n" ; cat "${safe_ix}" 207 | fi 208 | } 209 | 210 | backup () { 211 | # Archive index, safe and configuration. 212 | 213 | if [[ ! -f "${safe_backup}" ]] ; then 214 | if [[ -f "${safe_ix}" && -d "${safe_dir}" ]] ; then 215 | cp "${gpg_conf}" "gpg.conf.${today}" 216 | tar cf "${safe_backup}" "${safe_dir}" "${safe_ix}" \ 217 | "${BASH_SOURCE}" "gpg.conf.${today}" && \ 218 | printf "\nArchived %s\n" "${safe_backup}" 219 | rm -f "gpg.conf.${today}" 220 | else fail "Nothing to archive" ; fi 221 | else warn "${safe_backup} exists, skipping archive" ; fi 222 | } 223 | 224 | emit_pass () { 225 | # Use clipboard or stdout and clear after timeout. 226 | 227 | if [[ "${clip_dest}" = "screen" ]] ; then 228 | printf '\n%s\n' "$(cat ${1})" 229 | else ${clip} < "${1}" ; fi 230 | 231 | printf "\n" 232 | while [[ "${clip_timeout}" -gt 0 ]] ; do 233 | printf "\r\033[K Password on %s! Clearing in %.d" \ 234 | "${clip_dest}" "$((clip_timeout--))" ; sleep 1 235 | done 236 | printf "\r\033[K Clearing password from %s ..." "${clip_dest}" 237 | 238 | if [[ "${clip_dest}" = "screen" ]] ; then clear 239 | else printf "\n" ; printf "" | ${clip} ; fi 240 | } 241 | 242 | new_entry () { 243 | # Prompt for username and password. 244 | 245 | if [[ -z "${2+x}" ]] ; then read -r -p " 246 | Username (Enter to generate): " username 247 | else username="${2}" ; fi 248 | if [[ -z "${username}" ]] ; then 249 | username=$(generate_user "$@") ; fi 250 | 251 | if [[ -z "${3+x}" ]] ; then 252 | get_pass "Password for \"${username}\" (Enter to generate): " 253 | userpass="${password}" ; fi 254 | 255 | printf "\n" 256 | if [[ -z "${password}" ]] ; then 257 | userpass=$(generate_pass "$@") ; fi 258 | } 259 | 260 | print_help () { 261 | # Print help text. 262 | 263 | printf """ 264 | 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 265 | Purse can be used interactively or by passing one of the following options:\n 266 | * 'w' to write a password 267 | * 'r' to read a password 268 | * 'l' to list passwords 269 | * 'b' to create an archive for backup\n 270 | Options can also be passed on the command line.\n 271 | * Create a 20-character password for userName: 272 | ./purse.sh w userName 20\n 273 | * Read password for userName: 274 | ./purse.sh r userName\n 275 | * 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: 276 | ./purse.sh l 277 | ./purse.sh r userName@1574723625\n 278 | * Create an archive for backup: 279 | ./purse.sh b\n 280 | * Restore an archive from backup: 281 | tar xvf purse*tar\n""" 282 | } 283 | 284 | if [[ -z "${gpg}" || ! -x "${gpg}" ]] ; then fail "GnuPG is not available" ; fi 285 | 286 | if [[ ! -f "${gpg_conf}" ]] ; then fail "GnuPG config is not available" ; fi 287 | 288 | if [[ ! -d "${safe_dir}" ]] ; then mkdir -p "${safe_dir}" ; fi 289 | 290 | chmod -R 0700 "${safe_dir}" "${safe_ix}" 2>/dev/null 291 | 292 | if [[ -z "$(command -v ${clip})" ]] ; then 293 | warn "Clipboard not available, passwords will print to screen/stdout!" 294 | clip_dest="screen" 295 | elif [[ -n "${clip_args}" ]] ; then 296 | clip+=" ${clip_args}" 297 | fi 298 | 299 | username="" 300 | password="" 301 | action="" 302 | 303 | if [[ -n "${1+x}" ]] ; then action="${1}" ; fi 304 | 305 | while [[ -z "${action}" ]] ; do read -r -n 1 -p " 306 | Read or Write (or Help for more options): " action 307 | printf "\n" 308 | done 309 | 310 | if [[ "${action}" =~ ^([rR])$ ]] ; then read_pass "$@" 311 | elif [[ "${action}" =~ ^([wW])$ ]] ; then 312 | purse_keygroup="$(grep "group purse_keygroup" "${gpg_conf}")" 313 | if [[ -z "${purse_keygroup}" ]] ; then 314 | setup_keygroup 315 | fi 316 | printf "\n %s\n" "${purse_keygroup}" 317 | new_entry "$@" 318 | write_pass 319 | if [[ -n "${daily_backup}" ]] ; then backup ; fi 320 | elif [[ "${action}" =~ ^([lL])$ ]] ; then list_entry 321 | elif [[ "${action}" =~ ^([bB])$ ]] ; then backup 322 | else print_help ; fi 323 | 324 | tput setaf 2 ; printf "\nDone\n" ; tput sgr0 325 | --------------------------------------------------------------------------------