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