1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
#!/bin/sh
#
# pa - a simple password manager
pw_add() {
if yn "generate a password?"; then
pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-A-Za-z0-9-_}") ||
die "couldn't generate a password"
else
# 'sread()' is a simple wrapper function around 'read'
# to prevent user input from being printed to the terminal.
sread pass "enter a password"
[ "$pass" ] ||
die "password can't be empty"
sread pass2 "enter a password (again)"
# Disable this check as we dynamically populate the two
# passwords using the 'sread()' function.
# shellcheck disable=2154
[ "$pass" = "$pass2" ] ||
die "passwords don't match"
fi
mkdir -p "$(dirname "./$name")" ||
die "couldn't create category '$(dirname "./$name" | cut -c3-)'"
# Use 'age' to store the password in an encrypted file.
# A heredoc is used here instead of a 'printf' to avoid
# leaking the password through the '/proc' filesystem.
#
# Heredocs are sometimes implemented via temporary files,
# however this is typically done using 'mkstemp()' which
# is more secure than a leak in '/proc'.
$age --encrypt -R "$recipients_file" -o "./$name.age" <<-EOF ||
$pass
EOF
die "couldn't encrypt $name.age"
printf '%s\n' "saved '$name' to the store."
$git_enabled && git_add_and_commit "./$name.age" "add '$name'"
}
pw_edit() {
# Prefer /dev/shm because it's an in-memory
# space that we can use to store data without
# having bits laying around in sectors.
tmpdir=/dev/shm
# Fall back to /tmp - /dev/shm is Linux-only & /tmp
# and shared memory space on other operating systems
# have non-standard methods of setup/access.
[ -w /dev/shm ] || tmpdir=/tmp
tmpfile=$(printf 'mkstemp(%s/pa.XXXXXX)' $tmpdir | m4) ||
die "couldn't create a shared memory file"
trap 'rm -f "$tmpfile"' EXIT
if [ ! -f "$name.age" ]; then new=true; else new=false && {
$age --decrypt -i "$identities_file" -o "$tmpfile" "./$name.age" ||
die "couldn't decrypt $name.age"
}; fi
${EDITOR:-vi} "$tmpfile" ||
die "EDITOR exited non-zero"
[ -s "$tmpfile" ] && {
mkdir -p "$(dirname "./$name")" ||
die "couldn't create category '$(dirname "./$name" | cut -c3-)'"
$age --encrypt -R "$recipients_file" -o "./$name.age" "$tmpfile" ||
die "couldn't encrypt $name.age"
if $new; then printf '%s\n' "saved '$name' to the store."; fi
$git_enabled && git_add_and_commit "./$name.age" "edit '$name'"
}
}
pw_del() {
yn "delete password '$name'?" && {
rm -f "./$name.age"
rmdir -p "$(dirname "./$name")" 2>/dev/null || :
$git_enabled && git_add_and_commit "./$name.age" "delete '$name'"
}
}
pw_show() {
$age --decrypt -i "$identities_file" "./$name.age" ||
die "couldn't decrypt $name.age"
}
pw_list() {
find . -type f -name \*.age | sed 's/..//;s/\.age$//' | sort
}
git_add_and_commit() {
git add "$1" ||
die "couldn't git add $1"
git commit -qm "$2" ||
die "couldn't git commit $2"
}
rand_chars() {
# Generate random characters by reading '/dev/urandom' with the
# 'tr' command to translate the random bytes into a
# configurable character set.
#
# The 'dd' command is then used to read only the desired length.
#
# Regarding usage of '/dev/urandom' instead of '/dev/random'.
# See: https://www.2uo.de/myths-about-urandom
#
# $1 = number of chars to receive
# $2 = filter for the chars
LC_ALL=C tr -dc "$2" </dev/urandom | dd ibs=1 obs=1 count="$1" 2>/dev/null
}
yn() {
printf '%s [y/N]: ' "$1"
# Enable raw input to allow for a single byte to be read from
# stdin without needing to wait for the user to press Return.
[ -t 0 ] && stty -echo -icanon
# Read a single byte from stdin using 'dd'. POSIX 'read' has
# no support for single/'N' byte based input from the user.
answer=$(dd ibs=1 count=1 2>/dev/null)
# Disable raw input, leaving the terminal how we *should*
# have found it.
[ -t 0 ] && stty echo icanon
printf '%s\n' "$answer"
# Handle the answer here directly, enabling this function's
# return status to be used in place of checking for '[yY]'
# throughout this program.
glob "$answer" '[yY]'
}
sread() {
printf '%s: ' "$2"
# Disable terminal printing while the user inputs their
# password. POSIX 'read' has no '-s' flag which would
# effectively do the same thing.
[ -t 0 ] && stty -echo
read -r "$1"
[ -t 0 ] && stty echo
printf '\n'
}
glob() {
# This is a simple wrapper around a case statement to allow
# for simple string comparisons against globs.
#
# Example: if glob "Hello World" '* World'; then
#
# Disable this warning as it is the intended behavior.
# shellcheck disable=2254
case $1 in $2) return 0 ;; esac
return 1
}
die() {
printf '%s: %s.\n' "$(basename "$0")" "$1" >&2
exit 1
}
usage() {
printf %s "\
pa
a simple password manager
commands:
[a]dd [name] - Add a password entry.
[d]el [name] - Delete a password entry.
[e]dit [name] - Edit a password entry with ${EDITOR:-vi}.
[g]it [cmd] - Run git command in the password dir.
[l]ist - List all entries.
[s]how [name] - Show password for an entry.
env vars:
password dir: export PA_DIR=~/.local/share/pa/passwords
password length: export PA_LENGTH=50
password pattern: export PA_PATTERN=A-Za-z0-9-_
disable tracking: export PA_NOGIT=
"
exit 0
}
main() {
age=$(command -v age || command -v rage) ||
die "age not found, install per https://age-encryption.org"
age_keygen=$(command -v age-keygen || command -v rage-keygen) ||
die "age-keygen not found, install per https://age-encryption.org"
basedir=${XDG_DATA_HOME:=$HOME/.local/share}/pa
: "${PA_DIR:=$basedir/passwords}"
identities_file=$basedir/identities
recipients_file=$basedir/recipients
mkdir -p "$basedir" "$PA_DIR" ||
die "couldn't create pa directories"
cd "$PA_DIR" ||
die "couldn't change to password directory"
# Move any passwords hanging out in the old dir
# for backwards-compat reasons
mv "$basedir"/*.age "$PA_DIR" 2>/dev/null
# Ensure that globbing is disabled
# to avoid insecurities with word-splitting.
set -f
git_enabled=false
[ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null 2>&1 && git_enabled=true
$git_enabled && [ ! -d .git ] && {
git init -q
# Put something in user config if it's not set globally,
# because git doesn't allow to commit without it.
git config user.name >/dev/null || git config user.name pa
git config user.email >/dev/null || git config user.email ""
# Configure diff driver for age encrypted files that treats them as
# binary and decrypts them when a human-readable diff is requested.
git config diff.age.binary true
git config diff.age.textconv "$age --decrypt -i '$identities_file'"
# Assign this diff driver to all passwords.
printf '%s\n' '*.age diff=age' >.gitattributes
git_add_and_commit . "initial commit"
}
command=$1
shift
glob "$command" 'g*' && {
git "$@"
exit $?
}
# Combine the rest of positional arguments into
# a name and remove control characters from it
# so that a name can always be safely displayed.
name=$(printf %s "$*" | LC_ALL=C tr -d '[:cntrl:]')
glob "$command" '[ades]*' && [ -z "$name" ] &&
die "missing [name] argument"
glob "$name" '/*' || glob "$name" '*/' &&
die "name can't start or end with '/'"
glob "$name" '../*' || glob "$name" '*/../*' &&
die "category went out of bounds"
glob "$command" 'a*' && [ -f "$name.age" ] &&
die "password '$name' already exists"
glob "$command" '[ds]*' && [ ! -f "$name.age" ] &&
die "password '$name' doesn't exist"
# First, copy any existing identities files from the old
# storage location to the new one for backwards compat.
# Then, attempt key generation.
[ -f "$identities_file" ] ||
cp ~/.age/key.txt "$identities_file" 2>/dev/null ||
$age_keygen -o "$identities_file" 2>/dev/null
[ -f "$recipients_file" ] ||
$age_keygen -y -o "$recipients_file" "$identities_file" 2>/dev/null
# Ensure that we leave the terminal in a usable state on Ctrl+C.
[ -t 0 ] && trap 'stty echo icanon; trap - INT; kill -s INT 0' INT
case $command in
a*) pw_add ;;
d*) pw_del ;;
e*) pw_edit ;;
l*) pw_list ;;
s*) pw_show ;;
*) usage ;;
esac
}
# Ensure that debug mode is never enabled to
# prevent the password from leaking.
set +x
# Restrict permissions of any new files to
# only the current user.
umask 077
[ "$1" ] || usage && main "$@"