pa
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
#!/bin/sh # # pa - a simple password manager based on age pw_add() { name=$1 if yn "Generate a password?"; then pass=$(rand_chars "${PA_LENGTH:-50}" "${PA_PATTERN:-_A-Z-a-z-0-9}") [ "$pass" ] || 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 password" [ "$pass" ] || die "Password can't be empty" sread pass2 "Enter password (again)" # Disable this check as we dynamically populate the two # passwords using the 'sread()' function. # shellcheck disable=2154 [ "$pass" = "$pass2" ] || die "Passwords do not match" fi # 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" git_add_and_commit "./$name.age" "add '$name'" printf '%s\n' "Saved '$name' to the store." } pw_edit() { name=$1 [ -f "$name.age" ] || die "Couldn't access $name" # 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 # Reimplement mktemp here, because # mktemp isn't defined in POSIX editdir="$tmpdir/pa.$(rand_chars 8 '[:alnum:]')" tmpfile="$editdir/$name.age" # Handle nested items (/foo/bar.age) mkdir -p "$(dirname "$tmpfile")" || die "Couldn't create shared memory dir" trap 'rm -rf "$editdir"' EXIT age --decrypt -i "$identities_file" -o "$tmpfile" "./$name.age" || die "Couldn't decrypt $name.age" "${EDITOR:-vi}" "$tmpfile" age --encrypt -R "$recipients_file" -o "./$name.age" "$tmpfile" || die "Couldn't encrypt $name.age" git_add_and_commit "./$name.age" "edit '$name'" } pw_del() { yn "Delete pass file '$1'?" && { rm -f "./$1.age" # Remove empty parent directories of a password # entry. It's fine if this fails as it means that # another entry also lives in the same directory. rmdir -p "./${1%/*}" 2>/dev/null || : git_add_and_commit "./$1.age" "delete '$1'" } } pw_show() { age --decrypt -i "$identities_file" "./$1.age" || die "Couldn't decrypt $1.age" } pw_list() { find . -type f -name \*.age | sed 's/..//;s/\.age$//' | sort } git_add_and_commit() { if $git_enabled; then git add "$1" && git commit -qm "$2"; fi } 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 # password length, since head -c isn't POSIX compliant. # # 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 # # TODO: add more safety/compat here in case /dev/urandom doesn't exist 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. 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. stty echo icanon [ "$answer" ] && printf '%s' "$answer" printf '\n' # 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. stty -echo read -r "$1" 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 'error: %s.\n' "$1" >&2 exit 1 } usage() { printf %s "\ pa a simple password manager based on age 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}. [l]ist - List all entries. [s]how [name] - Show password for an entry. env vars: Password length: export PA_LENGTH=50 Password pattern: export PA_PATTERN=_A-Z-a-z-0-9 Password dir: export PA_DIR=~/.local/share/pa/passwords Disable tracking: export PA_NOGIT= " exit 0 } main() { 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 if [ -z "${PA_NOGIT+x}" ] && command -v git >/dev/null 2>&1; then git_enabled=true else git_enabled=false fi $git_enabled && [ ! -d .git ] && { git init # 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 -v age >/dev/null 2>&1 || die "age not found, install per https://github.com/FiloSottile/age" command -v age-keygen >/dev/null 2>&1 || die "age-keygen not found, install per https://github.com/FiloSottile/age" glob "$1" '[ades]*' && [ -z "$2" ] && die "Missing [name] argument" glob "$1" '[des]*' && [ ! -f "$2.age" ] && die "Password '$2' doesn't exist" glob "$1" 'a*' && [ -f "$2.age" ] && die "Password '$2' already exists" glob "$2" '*/*' && glob "$2" '*../*' && die "Category went out of bounds" glob "$2" '/*' && die "Category can't start with '/'" glob "$2" '*/*' && { mkdir -p "./${2%/*}" || die "Couldn't create category '${2%/*}'"; } # Restrict permissions of any new files to # only the current user. umask 077 # 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 exit or Ctrl+C. [ -t 1 ] && trap 'stty echo icanon; exit' INT EXIT case $1 in a*) pw_add "$2" ;; d*) pw_del "$2" ;; e*) pw_edit "$2" ;; l*) pw_list ;; s*) pw_show "$2" ;; *) usage ;; esac } # Ensure that debug mode is never enabled to # prevent the password from leaking. set +x [ "$1" ] || usage && main "$@"