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