1#!/usr/local/bin/bash
2# pass update - Password Store Extension (https://www.passwordstore.org/)
3# Copyright (C) 2017 Alexandre PUJOL <alexandre@pujol.io>.
4#
5#    This program is free software: you can redistribute it and/or modify
6#    it under the terms of the GNU General Public License as published by
7#    the Free Software Foundation, either version 3 of the License, or
8#    (at your option) any later version.
9#
10#    This program is distributed in the hope that it will be useful,
11#    but WITHOUT ANY WARRANTY; without even the implied warranty of
12#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13#    GNU General Public License for more details.
14#
15#    You should have received a copy of the GNU General Public License
16#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
17#
18
19# shellcheck disable=SC2086
20
21readonly VERSION="2.1"
22
23warning() { echo -e "Warning: ${*}" >&2; }
24
25cmd_update_version() {
26	cat <<-_EOF
27	$PROGRAM $COMMAND $VERSION - A pass extension that provides an
28                  easy flow for updating passwords.
29	_EOF
30}
31
32cmd_update_usage() {
33	cmd_update_version
34	echo
35	cat <<-_EOF
36	Usage:
37        $PROGRAM update [-h] [-n] [-l <s>] [-c | -p] [-p | -m]
38                    [-e <r>] [-i <r>] [-E] [-f] pass-names
39            Provide an interactive solution to update a set of passwords.
40            pass-names can refer either to password store path(s) or to
41            directory.
42
43            It prints the old password and waits for the user before generating
44            a new one. This behaviour can be changed using the provided options.
45
46            Only the first line of a password file is updated unless the
47            --multiline opiton is specified.
48
49    	Options:
50            -c, --clip        Write the password to the clipboard.
51            -n, --no-symbols  Do not use any non-alphanumeric characters.
52            -l, --length <s>  Provide a password length.
53            -p, --provide     Let the user specify a password by hand.
54            -m, --multiline   Update a multiline password.
55            -i, --include <r> Only update the passwords that match a regex.
56            -e, --exclude <r> Do not update the passwords that macth a regex.
57            -E, --edit        Edit the password using the default editor.
58            -f, --force       Force update.
59            -V, --version     Show version information.
60            -h, --help        Print this help message and exit.
61
62	More information may be found in the pass-update(1) man page.
63	_EOF
64}
65
66# Print the content of a passfile
67# $1: Path in the password store
68_show() {
69	local path="${1%/}" passfile="$PREFIX/$path.gpg"
70	[[ -f $passfile ]] && { $GPG -d "${GPG_OPTS[@]}" "$passfile" || exit $?; }
71}
72
73# Insert data to the password store
74# $1: Path in the password store
75# $2: Data to insert
76_insert() {
77	local path="${1%/}" data="$2" passfile="$PREFIX/$path.gpg"
78
79	set_git "$passfile"
80	mkdir -p -v "$PREFIX/$(dirname "$path")"
81	set_gpg_recipients "$(dirname "$path")"
82	if [[ $MULTLINE -eq 0 ]]; then
83		$GPG -e "${GPG_RECIPIENT_ARGS[@]}" -o "$passfile" "${GPG_OPTS[@]}" <<<"$data" || \
84			die "Error: Password encryption aborted."
85	else
86		echo -e "Enter the updated contents of $path and press Ctrl+D when finished:\n"
87		$GPG -e "${GPG_RECIPIENT_ARGS[@]}" -o "$passfile" "${GPG_OPTS[@]}" || \
88			die "Error: Password encryption aborted."
89	fi
90	git_add_file "$passfile" "Update password for $path to store."
91}
92
93cmd_update() {
94	# Sanity checks
95	[[ -z "${*}" ]] && die "Usage: $PROGRAM $COMMAND [-h] [-n] [-l <s>] [-c | -p] [-p | -m] [-e <r>] [-i <r>] [-E] [-f] pass-names"
96	[[ ! $LENGTH =~ ^[0-9]+$ ]] && die "Error: pass-length \"$LENGTH\" must be a number."
97	[[ -n "$CLIP" && $PROVIDED -eq 1 ]] && die "Error: cannot use the options --clip and --provide together"
98	[[ $MULTLINE -eq 1 && $PROVIDED -eq 1 ]] && die "Error: cannot use the options --multiline and --provide together"
99
100	# Get a curated list of path to update
101	typeset -a paths=() passfiles=()
102	local path passfile passdir file tmpfile
103	for path in "$@"; do
104		check_sneaky_paths "$path"
105		passfile="$PREFIX/${path%/}.gpg"
106		passdir="$PREFIX/${path%/}"
107		if [[ $path =~ [*] ]]; then
108			for file in "$PREFIX/"$path.gpg; do
109				if [[ -f "$file" ]]; then
110					tmpfile="${file#$PREFIX/}"
111					paths+=("${tmpfile%.gpg}")
112				fi
113			done
114		elif [[ -d "$passdir" ]]; then
115			mapfile -t passfiles < <(find "$passdir" -type f -iname '*.gpg' -printf "$path/%P\n")
116			for file in "${passfiles[@]}"; do
117				paths+=("${file%.gpg}")
118			done
119		elif [[ -f $passfile ]]; then
120			paths+=("$path")
121		else
122			warning "$path is not in the password store."
123		fi
124	done
125
126	local content oldpassword
127	for path in "${paths[@]}"; do
128		if [[ $EDIT -eq 0 ]]; then
129			content="$(_show "$path")"
130			oldpassword="$(echo "$content" | head -n 1)"
131			[[ -n "$INCLUDE" && ! "$oldpassword" =~ $INCLUDE ]] && continue
132			[[ -n "$EXCLUDE" && "$oldpassword" =~ $EXCLUDE ]] && continue
133
134			# Show old password
135			printf "\e[1m\e[37mChanging password for \e[4m%s\e[0m\n" "$path"
136			if [[ -z "$CLIP" ]]; then
137				printf "%s\n" "$content"
138			else
139				clip "$oldpassword" "$path"
140			fi
141
142			# Ask user for confirmation
143			if [[ $YES -eq 0 ]]; then
144				[[ $PROVIDED -eq 1 || $MULTLINE -eq 1 ]] && verb="provide" || verb="generate"
145				read -r -p "Are you ready to $verb a new password? [y/N] " response
146				[[ $response == [yY] ]] || continue
147			fi
148
149			# Update the password
150			if [[ $PROVIDED -eq 1 ]]; then
151				local password password_again
152				while true; do
153					read -r -p "Enter the new password for $path: " -s password || exit 1
154					echo
155					read -r -p "Retype the new password for $path: " -s password_again || exit 1
156					echo
157					if [[ "$password" == "$password_again" ]]; then
158						break
159					else
160						die "Error: the entered passwords do not match."
161					fi
162				done
163				_insert "$path" "$(echo "$content" | sed $'1c \\\n'"$(sed 's/[\/&]/\\&/g' <<<"$password")"$'\n')"
164			elif [[ $MULTLINE -eq 1 ]]; then
165				_insert "$path"
166			else
167				cmd_generate "$path" "$LENGTH" $SYMBOLS $CLIP '--in-place' || exit 1
168			fi
169		else
170			cmd_edit "$path"
171		fi
172	done
173}
174
175# Global options
176YES=0
177MULTLINE=0
178CLIP=""
179SYMBOLS=""
180PROVIDED=0
181EDIT=0
182INCLUDE=""
183EXCLUDE=""
184LENGTH="$GENERATED_LENGTH"
185
186# Getopt options
187small_arg="hVcfnl:pmEi:e:"
188long_arg="help,version,clip,force,no-symbols,length:,provide,multiline,edit,include:,exclude:"
189opts="$($GETOPT -o $small_arg -l $long_arg -n "$PROGRAM $COMMAND" -- "$@")"
190err=$?
191eval set -- "$opts"
192while true; do case $1 in
193	-c|--clip) CLIP="--clip"; shift ;;
194	-f|--force) YES=1; shift ;;
195	-n|--no-symbols) SYMBOLS="--no-symbols"; shift ;;
196	-p|--provide) PROVIDED=1; shift ;;
197	-l|--length) LENGTH="$2"; shift 2 ;;
198	-m|--multiline) MULTLINE=1; shift ;;
199	-i|--include) INCLUDE="$2"; shift 2 ;;
200	-e|--exclude) EXCLUDE="$2"; shift 2 ;;
201	-E|--edit) EDIT=1; shift ;;
202	-h|--help) shift; cmd_update_usage; exit 0 ;;
203	-V|--version) shift; cmd_update_version; exit 0 ;;
204	--) shift; break ;;
205esac done
206
207[[ $err -ne 0 ]] && cmd_update_usage && exit 1
208cmd_update "$@"
209