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
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'