my dotz
1#!/bin/sh
2#
3# pa - simple age-based password manager
4
5pw_add() {
6 name=$1
7
8 if yn "Generate a password?"; then
9 # Generate a password by reading '/dev/urandom' with the
10 # 'tr' command to translate the random bytes into a
11 # configurable character set.
12 #
13 # The 'dd' command is then used to read only the desired
14 # password length.
15 #
16 # Regarding usage of '/dev/urandom' instead of '/dev/random'.
17 # See: https://www.2uo.de/myths-about-urandom
18 pass=$(LC_ALL=C tr -dc "${PA_PATTERN:-_A-Z-a-z-0-9}" < /dev/urandom |
19 dd ibs=1 obs=1 count="${PA_LENGTH:-50}" 2>/dev/null)
20
21 else
22 # 'sread()' is a simple wrapper function around 'read'
23 # to prevent user input from being printed to the terminal.
24 sread pass "Enter password"
25 sread pass2 "Enter password (again)"
26
27 # Disable this check as we dynamically populate the two
28 # passwords using the 'sread()' function.
29 # shellcheck disable=2154
30 [ "$pass" = "$pass2" ] || die "Passwords do not match"
31 fi
32
33 [ "$pass" ] || die "Failed to generate a password"
34
35 # Mimic the use of an array for storing arguments by... using
36 # the function's argument list. This is very apt isn't it?
37 set -- -c
38
39 # Use 'age' to store the password in an encrypted file.
40 # A heredoc is used here instead of a 'printf' to avoid
41 # leaking the password through the '/proc' filesystem.
42 #
43 # Heredocs are sometimes implemented via temporary files,
44 # however this is typically done using 'mkstemp()' which
45 # is more secure than a leak in '/proc'.
46 age -r "$pubkey" -o "$name.age" <<-EOF &&
47 $pass
48 EOF
49 printf '%s\n' "Saved '$name' to the store."
50}
51
52pw_edit() {
53 name=$1
54
55 [ -f "$name.age" ] || die "Failed to access $name"
56
57 # we use /dev/shm because it's an in-memory
58 # space that we can use to store private data,
59 # and securely wipe it without worrying about
60 # residual badness
61 [ -d /dev/shm ] || die "Failed to access /dev/shm"
62
63 # get base dirname in case we're dealing with
64 # a nested item (foo/bar)
65 tmpfile="/dev/shm/pa/$name.txt"
66 tmpdir="$(dirname $tmpfile)"
67 mkdir -p "$tmpdir"
68 trap 'rm -rf /dev/shm/pa' EXIT
69
70 age -i ~/.age/key.txt --decrypt "$name.age" 2>/dev/null > "$tmpfile" ||
71 die "Could not decrypt $name.age"
72
73 "${EDITOR:-vi}" "$tmpfile"
74
75 [ -f "$tmpfile" ] || die "New password not saved"
76
77 rm "$name.age"
78 age -r "$pubkey" -o "$name.age" "$tmpfile"
79}
80
81pw_del() {
82 yn "Delete pass file '$1'?" && {
83 rm -f "$1.age"
84
85 # Remove empty parent directories of a password
86 # entry. It's fine if this fails as it means that
87 # another entry also lives in the same directory.
88 rmdir -p "${1%/*}" 2>/dev/null || :
89 }
90}
91
92pw_show() {
93 age -i ~/.age/key.txt --decrypt "$1.age" 2>/dev/null ||
94 die "Could not decrypt $1.age"
95}
96
97pw_list() {
98 find . -type f -name \*.age | sed 's/..//;s/\.age$//'
99}
100
101pw_gen() {
102 if yn "$HOME/.age/key.txt not detected, generate a new one?"; then
103 mkdir -p ~/.age
104 age-keygen -o ~/.age/key.txt
105 fi
106}
107
108yn() {
109 printf '%s [y/n]: ' "$1"
110
111 # Enable raw input to allow for a single byte to be read from
112 # stdin without needing to wait for the user to press Return.
113 stty -icanon
114
115 # Read a single byte from stdin using 'dd'. POSIX 'read' has
116 # no support for single/'N' byte based input from the user.
117 answer=$(dd ibs=1 count=1 2>/dev/null)
118
119 # Disable raw input, leaving the terminal how we *should*
120 # have found it.
121 stty icanon
122
123 printf '\n'
124
125 # Handle the answer here directly, enabling this function's
126 # return status to be used in place of checking for '[yY]'
127 # throughout this program.
128 glob "$answer" '[yY]'
129}
130
131sread() {
132 printf '%s: ' "$2"
133
134 # Disable terminal printing while the user inputs their
135 # password. POSIX 'read' has no '-s' flag which would
136 # effectively do the same thing.
137 stty -echo
138 read -r "$1"
139 stty echo
140
141 printf '\n'
142}
143
144glob() {
145 # This is a simple wrapper around a case statement to allow
146 # for simple string comparisons against globs.
147 #
148 # Example: if glob "Hello World" '* World'; then
149 #
150 # Disable this warning as it is the intended behavior.
151 # shellcheck disable=2254
152 case $1 in $2) return 0; esac; return 1
153}
154
155die() {
156 printf 'error: %s.\n' "$1" >&2
157 exit 1
158}
159
160usage() { printf %s "\
161pa 0.1.0 - age-based password manager
162=> [a]dd [name] - Create a new password, randomly generated
163=> [d]el [name] - Delete a password entry.
164=> [e]dit [name] - Edit a password entry with $EDITOR.
165=> [l]ist - List all entries.
166=> [s]how [name] - Show password for an entry.
167Password length: export PA_LENGTH=50
168Password pattern: export PA_PATTERN=_A-Z-a-z-0-9
169Store location: export PA_DIR=~/.local/share/pa
170"
171exit 0
172}
173
174main() {
175 : "${PA_DIR:=${XDG_DATA_HOME:=$HOME/.local/share}/pa}"
176
177 command -v age >/dev/null 2>&1 ||
178 die "age not found, install per https://github.com/FiloSottile/age"
179
180 command -v age-keygen >/dev/null 2>&1 ||
181 die "age-keygen not found, install per https://github.com/FiloSottile/age"
182
183 mkdir -p "$PA_DIR" ||
184 die "Couldn't create password directory"
185
186 cd "$PA_DIR" ||
187 die "Can't access password directory"
188
189 glob "$1" '[acdes]*' && [ -z "$2" ] &&
190 die "Missing [name] argument"
191
192 glob "$1" '[cds]*' && [ ! -f "$2.age" ] &&
193 die "Pass file '$2' doesn't exist"
194
195 glob "$1" 'a*' && [ -f "$2.age" ] &&
196 die "Pass file '$2' already exists"
197
198 glob "$2" '*/*' && glob "$2" '*../*' &&
199 die "Category went out of bounds"
200
201 glob "$2" '/*' &&
202 die "Category can't start with '/'"
203
204 glob "$2" '*/*' && { mkdir -p "${2%/*}" ||
205 die "Couldn't create category '${2%/*}'"; }
206
207 # Restrict permissions of any new files to
208 # only the current user.
209 umask 077
210
211 [ -f ~/.age/key.txt ] || pw_gen
212 pubkey=$(sed -n 's/.*\(age\)/\1/p' ~/.age/key.txt)
213
214 # Ensure that we leave the terminal in a usable
215 # state on exit or Ctrl+C.
216 [ -t 1 ] && trap 'stty echo icanon' INT EXIT
217
218 case $1 in
219 a*) pw_add "$2" ;;
220 d*) pw_del "$2" ;;
221 e*) pw_edit "$2" ;;
222 s*) pw_show "$2" ;;
223 l*) pw_list ;;
224 *) usage
225 esac
226}
227
228# Ensure that debug mode is never enabled to
229# prevent the password from leaking.
230set +x
231
232# Ensure that globbing is globally disabled
233# to avoid insecurities with word-splitting.
234set -f
235
236[ "$1" ] || usage && main "$@"