···11-#!/bin/sh
22-#
33-# pa - simple age-based password manager
44-55-pw_add() {
66- name=$1
77-88- if yn "Generate a password?"; then
99- # Generate a password by reading '/dev/urandom' with the
1010- # 'tr' command to translate the random bytes into a
1111- # configurable character set.
1212- #
1313- # The 'dd' command is then used to read only the desired
1414- # password length.
1515- #
1616- # Regarding usage of '/dev/urandom' instead of '/dev/random'.
1717- # See: https://www.2uo.de/myths-about-urandom
1818- pass=$(LC_ALL=C tr -dc "${PA_PATTERN:-_A-Z-a-z-0-9}" < /dev/urandom |
1919- dd ibs=1 obs=1 count="${PA_LENGTH:-50}" 2>/dev/null)
2020-2121- else
2222- # 'sread()' is a simple wrapper function around 'read'
2323- # to prevent user input from being printed to the terminal.
2424- sread pass "Enter password"
2525- sread pass2 "Enter password (again)"
2626-2727- # Disable this check as we dynamically populate the two
2828- # passwords using the 'sread()' function.
2929- # shellcheck disable=2154
3030- [ "$pass" = "$pass2" ] || die "Passwords do not match"
3131- fi
3232-3333- [ "$pass" ] || die "Failed to generate a password"
3434-3535- # Mimic the use of an array for storing arguments by... using
3636- # the function's argument list. This is very apt isn't it?
3737- set -- -c
3838-3939- # Use 'age' to store the password in an encrypted file.
4040- # A heredoc is used here instead of a 'printf' to avoid
4141- # leaking the password through the '/proc' filesystem.
4242- #
4343- # Heredocs are sometimes implemented via temporary files,
4444- # however this is typically done using 'mkstemp()' which
4545- # is more secure than a leak in '/proc'.
4646- age -r "$pubkey" -o "$name.age" <<-EOF &&
4747- $pass
4848- EOF
4949- printf '%s\n' "Saved '$name' to the store."
5050-}
5151-5252-pw_edit() {
5353- name=$1
5454-5555- [ -f "$name.age" ] || die "Failed to access $name"
5656-5757- # we use /dev/shm because it's an in-memory
5858- # space that we can use to store private data,
5959- # and securely wipe it without worrying about
6060- # residual badness
6161- [ -d /dev/shm ] || die "Failed to access /dev/shm"
6262-6363- # get base dirname in case we're dealing with
6464- # a nested item (foo/bar)
6565- tmpfile="/dev/shm/pa/$name.txt"
6666- tmpdir="$(dirname $tmpfile)"
6767- mkdir -p "$tmpdir"
6868- trap 'rm -rf /dev/shm/pa' EXIT
6969-7070- age -i ~/.age/key.txt --decrypt "$name.age" 2>/dev/null > "$tmpfile" ||
7171- die "Could not decrypt $name.age"
7272-7373- "${EDITOR:-vi}" "$tmpfile"
7474-7575- [ -f "$tmpfile" ] || die "New password not saved"
7676-7777- rm "$name.age"
7878- age -r "$pubkey" -o "$name.age" "$tmpfile"
7979-}
8080-8181-pw_del() {
8282- yn "Delete pass file '$1'?" && {
8383- rm -f "$1.age"
8484-8585- # Remove empty parent directories of a password
8686- # entry. It's fine if this fails as it means that
8787- # another entry also lives in the same directory.
8888- rmdir -p "${1%/*}" 2>/dev/null || :
8989- }
9090-}
9191-9292-pw_show() {
9393- age -i ~/.age/key.txt --decrypt "$1.age" 2>/dev/null ||
9494- die "Could not decrypt $1.age"
9595-}
9696-9797-pw_list() {
9898- find . -type f -name \*.age | sed 's/..//;s/\.age$//'
9999-}
100100-101101-pw_gen() {
102102- if yn "$HOME/.age/key.txt not detected, generate a new one?"; then
103103- mkdir -p ~/.age
104104- age-keygen -o ~/.age/key.txt
105105- fi
106106-}
107107-108108-yn() {
109109- printf '%s [y/n]: ' "$1"
110110-111111- # Enable raw input to allow for a single byte to be read from
112112- # stdin without needing to wait for the user to press Return.
113113- stty -icanon
114114-115115- # Read a single byte from stdin using 'dd'. POSIX 'read' has
116116- # no support for single/'N' byte based input from the user.
117117- answer=$(dd ibs=1 count=1 2>/dev/null)
118118-119119- # Disable raw input, leaving the terminal how we *should*
120120- # have found it.
121121- stty icanon
122122-123123- printf '\n'
124124-125125- # Handle the answer here directly, enabling this function's
126126- # return status to be used in place of checking for '[yY]'
127127- # throughout this program.
128128- glob "$answer" '[yY]'
129129-}
130130-131131-sread() {
132132- printf '%s: ' "$2"
133133-134134- # Disable terminal printing while the user inputs their
135135- # password. POSIX 'read' has no '-s' flag which would
136136- # effectively do the same thing.
137137- stty -echo
138138- read -r "$1"
139139- stty echo
140140-141141- printf '\n'
142142-}
143143-144144-glob() {
145145- # This is a simple wrapper around a case statement to allow
146146- # for simple string comparisons against globs.
147147- #
148148- # Example: if glob "Hello World" '* World'; then
149149- #
150150- # Disable this warning as it is the intended behavior.
151151- # shellcheck disable=2254
152152- case $1 in $2) return 0; esac; return 1
153153-}
154154-155155-die() {
156156- printf 'error: %s.\n' "$1" >&2
157157- exit 1
158158-}
159159-160160-usage() { printf %s "\
161161-pa 0.1.0 - age-based password manager
162162-=> [a]dd [name] - Create a new password, randomly generated
163163-=> [d]el [name] - Delete a password entry.
164164-=> [e]dit [name] - Edit a password entry with $EDITOR.
165165-=> [l]ist - List all entries.
166166-=> [s]how [name] - Show password for an entry.
167167-Password length: export PA_LENGTH=50
168168-Password pattern: export PA_PATTERN=_A-Z-a-z-0-9
169169-Store location: export PA_DIR=~/.local/share/pa
170170-"
171171-exit 0
172172-}
173173-174174-main() {
175175- : "${PA_DIR:=${XDG_DATA_HOME:=$HOME/.local/share}/pa}"
176176-177177- command -v age >/dev/null 2>&1 ||
178178- die "age not found, install per https://github.com/FiloSottile/age"
179179-180180- command -v age-keygen >/dev/null 2>&1 ||
181181- die "age-keygen not found, install per https://github.com/FiloSottile/age"
182182-183183- mkdir -p "$PA_DIR" ||
184184- die "Couldn't create password directory"
185185-186186- cd "$PA_DIR" ||
187187- die "Can't access password directory"
188188-189189- glob "$1" '[acdes]*' && [ -z "$2" ] &&
190190- die "Missing [name] argument"
191191-192192- glob "$1" '[cds]*' && [ ! -f "$2.age" ] &&
193193- die "Pass file '$2' doesn't exist"
194194-195195- glob "$1" 'a*' && [ -f "$2.age" ] &&
196196- die "Pass file '$2' already exists"
197197-198198- glob "$2" '*/*' && glob "$2" '*../*' &&
199199- die "Category went out of bounds"
200200-201201- glob "$2" '/*' &&
202202- die "Category can't start with '/'"
203203-204204- glob "$2" '*/*' && { mkdir -p "${2%/*}" ||
205205- die "Couldn't create category '${2%/*}'"; }
206206-207207- # Restrict permissions of any new files to
208208- # only the current user.
209209- umask 077
210210-211211- [ -f ~/.age/key.txt ] || pw_gen
212212- pubkey=$(sed -n 's/.*\(age\)/\1/p' ~/.age/key.txt)
213213-214214- # Ensure that we leave the terminal in a usable
215215- # state on exit or Ctrl+C.
216216- [ -t 1 ] && trap 'stty echo icanon' INT EXIT
217217-218218- case $1 in
219219- a*) pw_add "$2" ;;
220220- d*) pw_del "$2" ;;
221221- e*) pw_edit "$2" ;;
222222- s*) pw_show "$2" ;;
223223- l*) pw_list ;;
224224- *) usage
225225- esac
226226-}
227227-228228-# Ensure that debug mode is never enabled to
229229-# prevent the password from leaking.
230230-set +x
231231-232232-# Ensure that globbing is globally disabled
233233-# to avoid insecurities with word-splitting.
234234-set -f
235235-236236-[ "$1" ] || usage && main "$@"
11+/home/j3s/code/pa/pa