diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12c1fbf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Zmakefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f19df4a --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ + +var_exclude = ZPASS_.* XDG_.* REMOTE_.* DISPLAY CONFIGFILE TMPDIR +fct_exclude = _tty_on + +zpass: src/* + lxsh -o zpass -m --minimize-var --exclude-var "$(var_exclude)" --minimize-fct --exclude-fct "$(fct_exclude)" --remove-unused src/main.sh + +debug: src/* + lxsh -o zpass src/main.sh + +build: zpass + +install: build + mv zpass /usr/local/bin + +uninstall: + rm /usr/local/bin/zpass + +clear: + rm zpass diff --git a/README.md b/README.md index 2a5d052..1180f94 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,83 @@ # zpass -Basic and simple password management + +Basic and simple password management for UNIX using shell.
+Does not require any setup, only uses a password to encrypt the archive. + +Systems other than GNU/linux are untested at the moment + +# Installing + +## Dependencies + +Requires: +- gpg +- tar + +Optional: +- screen (key caching and clipboard time) +- openssh (for remote files) +- zenity (GUI prompt) +- kdialog (better GUI prompt in KDE) +- xclip (copy on X) +- wl-clipboard (copy on wayland) + +## Prebuilt + +From [zpkg](https://github.com/zawwz/zpkg) package repository + +## From source + +Requires [lxsh](https://github.com/zawwz/lxsh) + +Clone this repository then run `make install` + +# Use + +By design zpass uses encrypted archive files, wherein a file contains a value. +You can use predefined operations, or perform custom executions inside the archive. + +See `zpass -h` for information on operations and configuration + +When using `get` or `copy`, if the path entered is a folder, +zpass will look for a `default` file in this folder + +## Example use + +Create a file with `zpass c`. +A prompt will appear to use a password to encrypt the password archive file. +If you lose this password, you lose access to all contents of the archive. + +You can create new values with either `zpass add `, `zpass new `, or `zpass set ` + +To copy a value into the clipboard, use `zpass ` or `zpass copy ` + +## Configuration + +zpass will load by default the file `.config/zpass/default.conf` in your home directory + +### Configuring remote file + +You can configure zpass to use a file on a remote server. +You need SSH access to the target machine.
+Here is an example configuration: +``` +ZPASS_REMOTE_ADDR=user@example.com +ZPASS_SSH_ID=~/.ssh/id_rsa +``` + +### Making the cache volatile + +If you are caching keys, by default zpass uses `~/.cache` as a caching path. +This can be troublesome in case the machine stops before the cache timer runs out, +leaving a file containing the key in plaintext.
+This can be fixed by pointing the cache path to a volatile filesystem.
+For example: +``` +ZPASS_CACHE_PATH=/tmp/zpasscache +``` + +# Troubleshooting + +### Prompt keeps appearing even with correct password + +Make sure your gpg configuration is correct, you can run `gpg -c < /dev/null` to check diff --git a/src/archive.sh b/src/archive.sh new file mode 100644 index 0000000..45f423d --- /dev/null +++ b/src/archive.sh @@ -0,0 +1,101 @@ +#!/bin/sh + +# $1 = tmpdir , $2 = keyfile +unpack() { + rm -rf "$1" || return $? + mkdir -p "$1" || return $? + ( + set -e + cd "$1" + decrypt "$2" | tar -xf - 2>/dev/null + ) +} + +# $1 = tmpdir , $2 = keyfile +pack() +{ + # clean empty dirs + archive="archive_$(randalnum 5)" + ( + cd "$1" || exit $? + if [ -n "$2" ] + then + key=$(cat "$2") && rm "$2" || exit $? + else + key=$(new_key_with_confirm) || exit $? + fi + tar -cf - -- * | encrypt "$key" > "$1/$archive" || exit $? + ) || return $? + if [ -n "$ZPASS_REMOTE_ADDR" ] + then + [ -z "$ZPASS_PATH" ] && datapath="~/.local/share/zpass" + if [ -n "$ZPASS_SSH_ID" ] + then scp -i "$ZPASS_SSH_ID" "$1/$archive" "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" >/dev/null || return $? + else scp "$1/$archive" "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" >/dev/null || return $? + fi + rm -f "$1/$archive" 2>/dev/null + return 0 + else + mv -f "$1/$archive" "$file" + fi +} + +# $@ = command to execute inside archive +# set env __NOPACK to not repack after command +archive_exec() +{ + err=0 + # tmp files + tmpdir="$TMPDIR/zpass_$(randalnum 5)" + keyfile="$tmpdir/$(randalnum 5).key" + # operation + ( + # unpack + unpack "$tmpdir/archive" "$keyfile" || exit $? + # execute + (cd "$tmpdir/archive" && "$@") || exit $? + # repack + [ -z "$__NOPACK" ] && { pack "$tmpdir/archive" "$keyfile" || exit $?; } + exit 0 + ) || err=$? + # cleanup + rm -rf "$tmpdir" + return $err +} + +# no argument +create() { + if [ -f "$file" ] + then + tmpdir="$TMPDIR/zpass_$(randalnum 5)" + # pack n repack with no tmp key: create new + unpack "$tmpdir" || return $? + pack "$tmpdir" || { echo "Encryption error" >&2 && return 1 ; } + rm -rf "$tmpdir" + else + # if remote: file tmp + [ -n "$ZPASS_REMOTE_ADDR" ] && { + file="$TMPDIR/zpass_$(filehash)$ZPASS_EXTENSION" + } + # get key + [ -z "$ZPASS_KEY" ] && { + ZPASS_KEY=$(new_key_with_confirm) || { echo "Cancelled" >&2 && return 100 ; } + } + # create archive + tar -cf - -T /dev/null | encrypt "$ZPASS_KEY" > "$file" || { + echo "Encryption error" >&2 + # echo "$tmperr" >&2 + rm "$file" + return 1 + } + [ -n "$ZPASS_REMOTE_ADDR" ] && { + ssh "$ZPASS_REMOTE_ADDR" "mkdir -p '$datapath'" + if [ -n "$ZPASS_SSH_ID" ] + then scp -i "$ZPASS_SSH_ID" "$file" "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" >/dev/null || return $? + else scp "$file" "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" >/dev/null || return $? + fi + rm -rf "$file" 2>/dev/null + } + fi + return 0 +} diff --git a/src/cache.sh b/src/cache.sh new file mode 100644 index 0000000..bf67c77 --- /dev/null +++ b/src/cache.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +## Cache functions + +clear_cache() { + rm "$cachepath"/* +} + +write_cache() { + echo "$1" > "$cachepath/$(keyfile)" + delete_cache "$ZPASS_KEY_CACHE_TIME" +} + +get_key_cached() { + [ ! -f "$file" ] && return 0 + cat "$cachepath/$(keyfile)" 2>/dev/null +} + +# $1 = delay in sec +delete_cache() { + if [ "$1" -gt 0 ] 2>/dev/null + then + for I in $(screen -ls | grep "zpass_$(keyfile)" | awk '{print $1}') + do + screen -S "$I" -X stuff "^C" + done + screen -dmS "zpass_$(keyfile)" sh -c "sleep $1 ; $0 rmc" # call zpass with cache delete + else + rm -f "$cachepath/$(keyfile)" 2>/dev/null + fi +} + +clean_cache() { + # key cache + find "$cachepath" -type f ! -newermt @$(date -d "-$ZPASS_KEY_CACHE_TIME seconds" +%s) -print0 | xargs -0 rm + # tmp folders older than 5 min + rm -rd $(find "$TMPDIR" -maxdepth 1 -type d -name 'zpass_*' ! -mmin 5) +} diff --git a/src/clipboard.sh b/src/clipboard.sh new file mode 100644 index 0000000..8db7bf8 --- /dev/null +++ b/src/clipboard.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +clipboard() +{ + unset in + read -r in + while read -r ln + do + in="$in +$ln" + done + printf "%s" "$in" | xclip -selection clipboard + which wl-copy >/dev/null 2>&1 && printf "%s" "$in" | wl-copy +} + +# $1 = delay in sec +clipboard_clear() { + if [ -n "$1" ] + then + for I in $(screen -ls | grep "$fname"_clipboard | awk '{print $1}') + do + screen -S "$I" -X stuff "^C" + done + screen -dmS "$fname"_clipboard sh -c "sleep $1 + xclip -selection clipboard < /dev/null + which wl-copy 2>&1 && wl-copy < /dev/null + sleep 1" + else + echo | clipboard + fi +} + +copy_check() +{ + if ps -e | grep -qi wayland + then + which wl-copy >/dev/null 2>&1 || error 1 "ERROR: running wayland but wl-clipboard is not installed" + elif [ -n "$DISPLAY" ] + then + which xclip >/dev/null 2>&1 || error 1 "ERROR: running X but xclip is not installed" + else + error 1 "ERROR: no graphical server detected" + fi + return 0 +} diff --git a/src/config.sh b/src/config.sh new file mode 100644 index 0000000..8998f10 --- /dev/null +++ b/src/config.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# XDG config/cache +datapath=".local/share/zpass" +cachepath="$HOME/.cache/zpass" +configpath="$HOME/.config/zpass" +[ -n "$XDG_CONFIG_HOME" ] && configpath="$XDG_CONFIG_HOME/zpass" +[ -n "$XDG_CACHE_HOME" ] && cachepath="$XDG_CACHE_HOME/zpass" +[ -z "$CONFIGFILE" ] && CONFIGFILE="$configpath/default.conf" +[ -n "$XDG_DATA_HOME" ] && [ -z "$ZPASS_REMOTE_ADDR" ] && datapath="$XDG_DATA_HOME/zpass" + +[ -z "$TMPDIR" ] && TMPDIR=/tmp + +# stash env config +tmpenv="$TMPDIR/zpassenv_$(randalnum 5)" +env | grep '^ZPASS_.*=' | sed "s/'/'\\\''/g;s/=/='/;s/$/'/g" > "$tmpenv" + +# load config file +[ -f "$CONFIGFILE" ] && { . "$CONFIGFILE" || exit $? ; } + +. "$tmpenv" || exit $? +rm -f "$tmpenv" 2>/dev/null + +# resolve zpass_path +[ -n "$ZPASS_PATH" ] && datapath="$ZPASS_PATH" +[ -n "$ZPASS_CACHE_PATH" ] && cachepath="$ZPASS_CACHE_PATH" + +# default ZPASS config +[ -z "$ZPASS_FILE" ] && ZPASS_FILE=default +[ -z "$ZPASS_EXTENSION" ] && ZPASS_EXTENSION=.tar.gpg +[ -z "$ZPASS_KEY_CACHE_TIME" ] && ZPASS_KEY_CACHE_TIME=60 # in seconds +[ -z "$ZPASS_CLIPBOARD_TIME" ] && ZPASS_CLIPBOARD_TIME=30 # in seconds +[ -z "$ZPASS_UNK_OP_CALL" ] && ZPASS_UNK_OP_CALL=copy +[ -z "$ZPASS_RAND_LEN" ] && ZPASS_RAND_LEN=20 + +# datapath resolution +# remove tildes +datapath="${datapath#\~/}" +[ "$datapath" = '~' ] && datapath="" +# if not remote and not absolute: add HOME +[ -z "$ZPASS_REMOTE_ADDR" ] && [ "$(echo "$datapath" | cut -c1)" != '/' ] && datapath="$HOME/$datapath" + +file="$datapath/$ZPASS_FILE$ZPASS_EXTENSION" + +[ -z "$ZPASS_REMOTE_ADDR" ] && { mkdir -p "$datapath" 2>/dev/null || error 1 "Could not create '$datapath'"; } +mkdir -p "$cachepath" 2>/dev/null && chmod -R go-rwx "$cachepath" 2>/dev/null diff --git a/src/crypt.sh b/src/crypt.sh new file mode 100644 index 0000000..92f7f20 --- /dev/null +++ b/src/crypt.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +# $1 = key +encrypt() { + gpg --pinentry-mode loopback --batch --passphrase "$1" -o - -c - +} + +# $1 = key , $2 = keyfile to write +decrypt_with_key() +{ + gpg --pinentry-mode loopback --batch --passphrase "$1" -o - -d "$file" 2>/dev/null && ret=$? && [ -n "$2" ] && echo "$1" > "$2" + return $ret +} + +# $1 = keyfile to write +decrypt() +{ + # get remote file + [ -n "$ZPASS_REMOTE_ADDR" ] && { + file="$TMPDIR/zpass_$(filehash)$ZPASS_EXTENSION" + [ -z "$ZPASS_PATH" ] && datapath="~/.local/share/zpass" + if [ -n "$ZPASS_SSH_ID" ] + then scp -i "$ZPASS_SSH_ID" "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" "$file" >/dev/null || return $? + else scp "$ZPASS_REMOTE_ADDR:$datapath/$ZPASS_FILE$ZPASS_EXTENSION" "$file" >/dev/null || return $? + fi + } + cat "$file" >/dev/null 2>&1 || { echo "File doesn't exist. Use 'zpass create' to create the file" >&2 && return 1; } # no file + + if [ -n "$ZPASS_KEY" ] + then # key given already + decrypt_with_key "$ZPASS_KEY" "$1" ; ret=$? + else # prompt for key + # attempt decrypt from cache + key=$(get_key_cached) && decrypt_with_key "$key" "$1" + ret=$? + if [ $ret -ne 0 ] + then + # cache was incorrect: delete + delete_cache >/dev/null 2>&1 + # try loop + tries=0 + while [ $ret -ne 0 ] && [ $tries -lt 3 ] + do + key=$(ask_key) || { echo "Cancelled" >&2 && return 100 ; } + tries=$((tries+1)) + decrypt_with_key "$key" "$1" ; ret=$? + done + fi + fi + + # remove temporary file + [ -n "$ZPASS_REMOTE_ADDR" ] && rm -rf "$file" 2>/dev/null + + [ $ret -ne 0 ] && { echo "Could not decrypt '$file'" >&2 ; } + return $ret +} diff --git a/src/file.sh b/src/file.sh new file mode 100644 index 0000000..fad9fb7 --- /dev/null +++ b/src/file.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +list_files() { + _cmd_ "cd '$datapath' 2>/dev/null && find . -maxdepth 1 -type f -regex '.*$ZPASS_EXTENSION\$'" | sed 's/$(escape_chars "$ZPASS_EXTENSION")\$//g; s|.*/||g' +} + +remove_files() +{ + for N + do + _cmd_ "rm '$datapath/$N$ZPASS_EXTENSION'" || exit $? + done +} diff --git a/src/help.sh b/src/help.sh new file mode 100644 index 0000000..b69b9ff --- /dev/null +++ b/src/help.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +fname=$(basename "$0") +usage() +{ + echo "$fname [options] +[Global Operations]: + list-files List eligible files in data path. Shortcut 'lsf' + cache-clear Delete all cached keys. Shortcut 'cc' + help Display help + rm-file Remove files. Shortcut 'rmf' +[File Operations]: + ls [path] List contents at given path + tree List all contents + create Create a file or change key + get Get values of targets + copy Copy the target value to clipboard. Shortcut 'x' + set Set the value of target + add Prompt for input value on paths + new Generate a random password at target + rm Delete targets + mv Move targets + exec Execute the following command inside the archive. + cached Returns wether or not a key is currently cached. Shortcut 'ch' + rm-cache Delete the cached key for this file. Shortcut 'rmc' + +[Config]: + *Variable* *Default value* *Description* + ------------------------------------------------------------------------------------------------------------------------ + CONFIGFILE '\$XDG_CONFIG_HOME/zpass/defaut.conf' Path to the config file to load + ZPASS_PATH '\$XDG_DATA_HOME/zpass' Folder containing password files + ZPASS_CACHE_PATH '\$XDG_CACHE_HOME/zpass' Path used for caching keys + ZPASS_FILE 'default' File to use for operations + ZPASS_KEY Key to use for encrypting/decrypting files + ZPASS_KEY_CACHE_TIME '60' Time a key stays in cache for decrypting, in seconds + ZPASS_CLIPBOARD_TIME '30' Time until clipboard gets cleared after copy, in seconds + ZPASS_UNK_OP_CALL 'copy' Operation to call on unrecognized first argument + ZPASS_RAND_LEN Length of random passwords generated by 'new' + ZPASS_REMOTE_ADDR SSH server the file is on + ZPASS_REMOTE_PORT '22' SSH server port + ZPASS_SSH_ID SSH private key to use + +All operations can be shortened to their first char unless specified +Unknown first argument will perform the operation described in 'ZPASS_UNK_OP_CALL' on that argument +" +} diff --git a/src/main.sh b/src/main.sh new file mode 100644 index 0000000..2319a90 --- /dev/null +++ b/src/main.sh @@ -0,0 +1,34 @@ +#!/bin/lxsh + +%include util.sh config.sh *.sh + +## pre exec + +clean_cache 2>/dev/null +[ $# -lt 1 ] && usage && return 1 + +arg=$1 +shift 1 + +## exec + +case $arg in + -h|h|help) usage && exit 1;; + lsf|list-files) list_files ;; + rmf|rm-file) remove_files "$@" ;; + cc|cache-clear) clear_cache 2>/dev/null ;; + ch|cached) get_key_cached >/dev/null 2>&1 ;; + rmc|rm-cache) delete_cache 0 >/dev/null 2>&1 ;; + c|create) create ;; + t|tree) _tree "$@" ;; + s|set) _set "$@" ;; + a|add) add "$@" ;; + n|new) new "$@" ;; + g|get) get "$@" ;; + x|copy) copy "$1" ;; + e|exec) archive_exec "$@" ;; + l|ls|list) sanitize_paths "$@" && __NOPACK=y archive_exec ls -Apw1 -- "$@" ;; + r|rm) sanitize_paths "$@" && archive_exec rm -rf -- "$@" ;; + m|mv) sanitize_paths "$@" && archive_exec mv -f -- "$@" ;; + *) [ -n "$ZPASS_UNK_OP_CALL" ] && "$0" $ZPASS_UNK_OP_CALL "$arg" "$@" ;; +esac diff --git a/src/operation.sh b/src/operation.sh new file mode 100644 index 0000000..3aec44e --- /dev/null +++ b/src/operation.sh @@ -0,0 +1,75 @@ +#!/bin/sh + +# $@ = paths +_tree() +{ + if [ $# -gt 0 ] + then + fulltree=$(decrypt | tar -tf - 2>/dev/null) || exit $?; + + for N + do + [ $# -gt 1 ] && echo "> $N:" + echo "$fulltree" | grep "^$(escape_chars "$N")" | sed "s|^$N||g ; "' s|^/||g ; /\/$/d ; /^$/d' + done + + else + { decrypt | tar -tf - 2>/dev/null || exit $?; } | sed '/\/$/d ; /^$/d' + fi +} + +# $@ = paths +get() +{ + [ $# -lt 1 ] && return 1 + __NOPACK=y archive_exec sh -c ' + for N + do + ( + cat "$N" 2>/dev/null && exit 0 + [ -d "$1" ] && cat "$N/default" 2>/dev/null && exit 0 + exit 1 + ) || { echo "$N: not found" >&2 && exit 1; } + done + ' sh "$@" +} + +# $1 = path +copy() +{ + copy_check || return $? + { get "$1" || return $?; } | remove_trailing_newline | clipboard && clipboard_clear "$ZPASS_CLIPBOARD_TIME" +} + +# $@ = path +new() +{ + [ $# -lt 1 ] && return 1 + archive_exec sh -c " + for N + do + mkdir -p \"\$(dirname \"\$N\")\" || exit \$? + { tr -cd 'a-zA-Z0-9!-.' < /dev/urandom | head -c $ZPASS_RAND_LEN && echo; } > \"\$N\" || exit \$? + done + " sh "$@" +} + +# $1 = path , $@ = value +_set() +{ + [ $# -lt 1 ] && return 1 + ref=$1 + shift 1 + archive_exec sh -c "mkdir -p '$(dirname "$ref")' && printf '%s\n' '$*' > '$ref'" +} + +add() +{ + [ $# -lt 1 ] && return 1 + archive_exec true # prompt for the key + for N + do + val=$(prompt_password "New value for $N") || return $? + _set "$N" "$val" || return $? + done +} diff --git a/src/prompt.sh b/src/prompt.sh new file mode 100644 index 0000000..c466af2 --- /dev/null +++ b/src/prompt.sh @@ -0,0 +1,62 @@ +#!/bin/sh + +# $1 = prompt +console_prompt_hidden() +{ + ( + _tty_on() { stty echo; } + trap _tty_on INT + local prompt + printf "%s" "$1" >&2 + stty -echo + read -r prompt || { stty echo; return 1; } + stty echo + printf "\n" >&2 + echo "$prompt" + ) +} + +# $1 = prompt message +prompt_password() { + if [ -n "$DISPLAY" ] + then + if which kdialog >/dev/null 2>&2 + then kdialog --title "$fname" --password "$1" 2>/dev/null + else zenity --title "$fname" --password 2>/dev/null + fi + else + console_prompt_hidden "$1: " + fi +} + +# $1 = message +error_dialog() { + if which kdialog >/dev/null 2>&2 + then kdialog --title "$fname" --error "$1" 2>/dev/null + else zenity --title "$fname" --error --text="$1" 2>/dev/null + fi +} + +new_key_with_confirm() +{ + [ -n "$ZPASS_KEY" ] && echo "$ZPASS_KEY" && return 0 + pass1=1 + pass2=2 + while [ "$pass1" != "$pass2" ] + do + pass1=$(prompt_password "Enter new key") || error 100 "Cancelled" + pass2=$(prompt_password "Confirm key") || error 100 "Cancelled" + [ "$pass1" != "$pass2" ] && error_dialog "Passwords do not match.\nTry again" + done + write_cache "$pass1" & + echo "$pass1" +} + +# $1 = prompt message +ask_key() { + message="Enter key" + [ -n "$1" ] && message="$1" + key=$(prompt_password "$message") || return $? + write_cache "$key" & + echo "$key" +} diff --git a/src/util.sh b/src/util.sh new file mode 100644 index 0000000..b1cbf40 --- /dev/null +++ b/src/util.sh @@ -0,0 +1,60 @@ +#!/bin/sh + +error(){ + ret=$1 && shift 1 && echo "$*" >&2 && exit $ret +} + +randalnum() { + tr -cd '[a-zA-Z]' < /dev/urandom | head -c $1 +} + +# $* = input +escape_chars() { + echo "$*" | sed 's|\.|\\\.|g;s|/|\\/|g' +} + +remove_trailing_newline() { + awk 'NR>1{print PREV} {PREV=$0} END{printf("%s",$0)}' +} + +# $@ = paths +sanitize_paths() +{ + for N + do + echo "$N" | grep -q "^/" && echo "Path cannot start with /" >&2 && return 1 + echo "$N" | grep -qw ".." && echo "Path cannot contain .." >&2 && return 1 + done + return 0 +} + +# $1 = file +getpath() { + if [ -n "$ZPASS_REMOTE_ADDR" ] + then echo "$ZPASS_REMOTE_PORT:$ZPASS_REMOTE_ADDR:$file" + else readlink -f "$file" + fi +} + +# $1 = file +filehash(){ + getpath "$file" | md5sum | cut -d' ' -f1 +} + +keyfile(){ + printf "%s.key" "$(filehash)" +} + +_cmd_() { + if [ -n "$ZPASS_REMOTE_ADDR" ] + then + if [ -n "$ZPASS_SSH_ID" ] + then + ssh -i "$ZPASS_SSH_ID" "$ZPASS_REMOTE_ADDR" "$@" || return $? + else + ssh "$ZPASS_REMOTE_ADDR" "$@" || return $? + fi + else + sh -c "$*" + fi +}