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