Merge branch 'master' of http://lair.fifthhorseman.net/~jrollins/git/monkeysphere
[monkeysphere.git] / src / common
1 # -*-shell-script-*-
2
3 # Shared sh functions for the monkeysphere
4 #
5 # Written by
6 # Jameson Rollins <jrollins@fifthhorseman.net>
7 #
8 # Copyright 2008, released under the GPL, version 3 or later
9
10 # all-caps variables are meant to be user supplied (ie. from config
11 # file) and are considered global
12
13 ########################################################################
14 ### COMMON VARIABLES
15
16 # managed directories
17 ETC="/etc/monkeysphere"
18 export ETC
19
20 ########################################################################
21 ### UTILITY FUNCTIONS
22
23 error() {
24     log "$1"
25     ERR=${2:-'1'}
26 }
27
28 failure() {
29     echo "$1" >&2
30     exit ${2:-'1'}
31 }
32
33 # write output to stderr
34 log() {
35     echo -n "ms: " >&2
36     echo "$@" >&2
37 }
38
39 loge() {
40     echo "$@" >&2
41 }
42
43 # cut out all comments(#) and blank lines from standard input
44 meat() {
45     grep -v -e "^[[:space:]]*#" -e '^$'
46 }
47
48 # cut a specified line from standard input
49 cutline() {
50     head --line="$1" | tail -1
51 }
52
53 # check that characters are in a string (in an AND fashion).
54 # used for checking key capability
55 # check_capability capability a [b...]
56 check_capability() {
57     local usage
58     local capcheck
59
60     usage="$1"
61     shift 1
62
63     for capcheck ; do
64         if echo "$usage" | grep -q -v "$capcheck" ; then
65             return 1
66         fi
67     done
68     return 0
69 }
70
71 # convert escaped characters from gpg output back into original
72 # character
73 # FIXME: undo all escape character translation in with-colons gpg output
74 unescape() {
75     echo "$1" | sed 's/\\x3a/:/'
76 }
77
78 # remove all lines with specified string from specified file
79 remove_line() {
80     local file
81     local string
82
83     file="$1"
84     string="$2"
85
86     if [ "$file" -a "$string" ] ; then
87         grep -v "$string" "$file" | sponge "$file"
88     fi
89 }
90
91 # translate ssh-style path variables %h and %u
92 translate_ssh_variables() {
93     local uname
94     local home
95
96     uname="$1"
97     path="$2"
98
99     # get the user's home directory
100     userHome=$(getent passwd "$uname" | cut -d: -f6)
101
102     # translate '%u' to user name
103     path=${path/\%u/"$uname"}
104     # translate '%h' to user home directory
105     path=${path/\%h/"$userHome"}
106
107     echo "$path"
108 }
109
110 ### CONVERSION UTILITIES
111
112 # output the ssh key for a given key ID
113 gpg2ssh() {
114     local keyID
115     
116     keyID="$1"
117
118     gpg --export "$keyID" | openpgp2ssh "$keyID" 2> /dev/null
119 }
120
121 # output the ssh key for a given secret key ID
122 gpgsecret2ssh() {
123     local keyID
124
125     #keyID="$1" #TMP
126     # only use last 16 characters until openpgp2ssh can take all 40 #TMP
127     keyID=$(echo "$1" | cut -c 25-) #TMP
128
129     gpg --export-secret-key "$keyID" | openpgp2ssh "$keyID" 2> /dev/null
130 }
131
132 # output known_hosts line from ssh key
133 ssh2known_hosts() {
134     local host
135     local key
136
137     host="$1"
138     key="$2"
139
140     echo -n "$host "
141     echo -n "$key" | tr -d '\n'
142     echo " MonkeySphere${DATE}"
143 }
144
145 # output authorized_keys line from ssh key
146 ssh2authorized_keys() {
147     local userID
148     local key
149     
150     userID="$1"
151     key="$2"
152
153     echo -n "$key" | tr -d '\n'
154     echo " MonkeySphere${DATE} ${userID}"
155 }
156
157 # convert key from gpg to ssh known_hosts format
158 gpg2known_hosts() {
159     local host
160     local keyID
161
162     host="$1"
163     keyID="$2"
164
165     # NOTE: it seems that ssh-keygen -R removes all comment fields from
166     # all lines in the known_hosts file.  why?
167     # NOTE: just in case, the COMMENT can be matched with the
168     # following regexp:
169     # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
170     echo -n "$host "
171     gpg2ssh "$keyID" | tr -d '\n'
172     echo " MonkeySphere${DATE}"
173 }
174
175 # convert key from gpg to ssh authorized_keys format
176 gpg2authorized_keys() {
177     local userID
178     local keyID
179
180     userID="$1"
181     keyID="$2"
182
183     # NOTE: just in case, the COMMENT can be matched with the
184     # following regexp:
185     # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
186     gpg2ssh "$keyID" | tr -d '\n'
187     echo " MonkeySphere${DATE} ${userID}"
188 }
189
190 ### GPG UTILITIES
191
192 # retrieve all keys with given user id from keyserver
193 # FIXME: need to figure out how to retrieve all matching keys
194 # (not just first N (5 in this case))
195 gpg_fetch_userid() {
196     local userID
197
198     userID="$1"
199
200     log -n " checking keyserver $KEYSERVER... "
201     echo 1,2,3,4,5 | \
202         gpg --quiet --batch --with-colons \
203         --command-fd 0 --keyserver "$KEYSERVER" \
204         --search ="$userID" > /dev/null 2>&1
205     loge "done."
206 }
207
208 # get the full fingerprint of a key ID
209 get_key_fingerprint() {
210     local keyID
211
212     keyID="$1"
213
214     gpg --list-key --with-colons --fixed-list-mode \
215         --with-fingerprint --with-fingerprint "$keyID" | \
216         grep '^fpr:' | grep "$keyID" | cut -d: -f10
217 }
218
219 ########################################################################
220 ### PROCESSING FUNCTIONS
221
222 # userid and key policy checking
223 # the following checks policy on the returned keys
224 # - checks that full key has appropriate valididy (u|f)
225 # - checks key has specified capability (REQUIRED_*_KEY_CAPABILITY)
226 # - checks that requested user ID has appropriate validity
227 # (see /usr/share/doc/gnupg/DETAILS.gz)
228 # output is one line for every found key, in the following format:
229 #
230 # flag fingerprint
231 #
232 # "flag" is an acceptability flag, 0 = ok, 1 = bad
233 # "fingerprint" is the fingerprint of the key
234 #
235 # expects global variable: "MODE"
236 process_user_id() {
237     local userID
238     local requiredCapability
239     local requiredPubCapability
240     local gpgOut
241     local type
242     local validity
243     local keyid
244     local uidfpr
245     local usage
246     local keyOK
247     local uidOK
248     local lastKey
249     local lastKeyOK
250     local fingerprint
251
252     userID="$1"
253
254     # set the required key capability based on the mode
255     if [ "$MODE" = 'known_hosts' ] ; then
256         requiredCapability="$REQUIRED_HOST_KEY_CAPABILITY"
257     elif [ "$MODE" = 'authorized_keys' ] ; then
258         requiredCapability="$REQUIRED_USER_KEY_CAPABILITY"      
259     fi
260     requiredPubCapability=$(echo "$requiredCapability" | tr "[:lower:]" "[:upper:]")
261
262     # if CHECK_KEYSERVER variable set to true, check the keyserver
263     # for the user ID
264     if [ "$CHECK_KEYSERVER" = "true" ] ; then
265         gpg_fetch_userid "$userID"
266     fi
267
268     # output gpg info for (exact) userid and store
269     gpgOut=$(gpg --list-key --fixed-list-mode --with-colon \
270         --with-fingerprint --with-fingerprint \
271         ="$userID" 2>/dev/null)
272
273     # if the gpg query return code is not 0, return 1
274     if [ "$?" -ne 0 ] ; then
275         log "  - key not found."
276         return 1
277     fi
278
279     # loop over all lines in the gpg output and process.
280     # need to do it this way (as opposed to "while read...") so that
281     # variables set in loop will be visible outside of loop
282     echo "$gpgOut" | cut -d: -f1,2,5,10,12 | \
283     while IFS=: read -r type validity keyid uidfpr usage ; do
284         # process based on record type
285         case $type in
286             'pub') # primary keys
287                 # new key, wipe the slate
288                 keyOK=
289                 uidOK=
290                 lastKey=pub
291                 lastKeyOK=
292                 fingerprint=
293
294                 log " primary key found: $keyid"
295
296                 # if overall key is not valid, skip
297                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
298                     log "  - unacceptable primary key validity ($validity)."
299                     continue
300                 fi
301                 # if overall key is disabled, skip
302                 if check_capability "$usage" 'D' ; then
303                     log "  - key disabled."
304                     continue
305                 fi
306                 # if overall key capability is not ok, skip
307                 if ! check_capability "$usage" $requiredPubCapability ; then
308                     log "  - unacceptable primary key capability ($usage)."
309                     continue
310                 fi
311
312                 # mark overall key as ok
313                 keyOK=true
314
315                 # mark primary key as ok if capability is ok
316                 if check_capability "$usage" $requiredCapability ; then
317                     lastKeyOK=true
318                 fi
319                 ;;
320             'uid') # user ids
321                 # if an acceptable user ID was already found, skip
322                 if [ "$uidOK" ] ; then
323                     continue
324                 fi
325                 # if the user ID does not match, skip
326                 if [ "$(unescape "$uidfpr")" != "$userID" ] ; then
327                     continue
328                 fi
329                 # if the user ID validity is not ok, skip
330                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
331                     continue
332                 fi
333
334                 # mark user ID acceptable
335                 uidOK=true
336
337                 # output a line for the primary key
338                 # 0 = ok, 1 = bad
339                 if [ "$keyOK" -a "$uidOK" -a "$lastKeyOK" ] ; then
340                     log "  * acceptable key found."
341                     echo "0:${fingerprint}"
342                 else
343                     echo "1:${fingerprint}"
344                 fi
345                 ;;
346             'sub') # sub keys
347                 # unset acceptability of last key
348                 lastKey=sub
349                 lastKeyOK=
350                 fingerprint=
351
352                 # if sub key validity is not ok, skip
353                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
354                     continue
355                 fi
356                 # if sub key capability is not ok, skip
357                 if ! check_capability "$usage" $requiredCapability ; then
358                     continue
359                 fi
360
361                 # mark sub key as ok
362                 lastKeyOK=true
363                 ;;
364             'fpr') # key fingerprint
365                 fingerprint="$uidfpr"
366
367                 # if the last key was the pub key, skip
368                 if [ "$lastKey" = pub ] ; then
369                     continue
370                 fi
371                 
372                 # output a line for the last subkey
373                 # 0 = ok, 1 = bad
374                 if [ "$keyOK" -a "$uidOK" -a "$lastKeyOK" ] ; then
375                     log "  * acceptable key found."
376                     echo "0:${fingerprint}"
377                 else
378                     echo "1:${fingerprint}"
379                 fi
380                 ;;
381         esac
382     done
383 }
384
385 # process a single host in the known_host file
386 process_host_known_hosts() {
387     local host
388     local userID
389     local ok
390     local keyid
391     local tmpfile
392     local returnCode
393
394     # default return code is 1, which assumes no key was found
395     returnCode=1
396
397     host="$1"
398
399     log "processing host: $host"
400
401     userID="ssh://${host}"
402
403     for line in $(process_user_id "ssh://${host}") ; do
404         ok=$(echo "$line" | cut -d: -f1)
405         keyid=$(echo "$line" | cut -d: -f2)
406
407         sshKey=$(gpg2ssh "$keyid")
408         # remove the old host key line
409         remove_line "$KNOWN_HOSTS" "$sshKey"
410         # if key OK, add new host line
411         if [ "$ok" -eq '0' ] ; then
412             # hash if specified
413             if [ "$HASH_KNOWN_HOSTS" = 'true' ] ; then
414                 # FIXME: this is really hackish cause ssh-keygen won't
415                 # hash from stdin to stdout
416                 tmpfile=$(mktemp)
417                 ssh2known_hosts "$host" "$sshKey" > "$tmpfile"
418                 ssh-keygen -H -f "$tmpfile" 2> /dev/null
419                 cat "$tmpfile" >> "$KNOWN_HOSTS"
420                 rm -f "$tmpfile" "${tmpfile}.old"
421             else
422                 ssh2known_hosts "$host" "$sshKey" >> "$KNOWN_HOSTS"
423             fi
424             # set return code to be 0, since a key was found
425             returnCode=0
426         fi
427         return "$returnCode"
428     done
429
430     return "$returnCode"
431 }
432
433 # update the known_hosts file for a set of hosts listed on command
434 # line
435 update_known_hosts() {
436     local host
437     local returnCode
438
439     # default return code is 0, which assumes a key was found for
440     # every host.  code will be set to 1 if a key is not found for at
441     # least one host
442     returnCode=0
443
444     # set the trap to remove any lockfiles on exit
445     trap "lockfile-remove $KNOWN_HOSTS" EXIT
446
447     # create a lockfile on known_hosts
448     lockfile-create "$KNOWN_HOSTS"
449
450     for host ; do
451         # process the host, change return code if host key not found
452         process_host_known_hosts "$host" || returnCode=1
453         
454         # touch the lockfile, for good measure.
455         lockfile-touch --oneshot "$KNOWN_HOSTS"
456     done
457
458     # remove the lockfile
459     lockfile-remove "$KNOWN_HOSTS"
460
461     return "$returnCode"
462 }
463
464 # process known_hosts file, going through line-by-line, extract each
465 # host, and process with the host processing function
466 process_known_hosts() {
467     local returnCode
468
469     log "processing known_hosts file..."
470
471     # default return code is 0, which assumes a key was found for
472     # every host.  code will be set to 1 if a key is not found for at
473     # least one host
474     returnCode=0
475
476     # take all the hosts from the known_hosts file (first field), grep
477     # out all the hashed hosts (lines starting with '|')...
478     for line in $(cat "$KNOWN_HOSTS" | meat | cut -d ' ' -f 1 | grep -v '^|.*$') ; do
479         # break up hosts into separate words
480         update_known_hosts $(echo "$line" | tr , ' ') || returnCode=1
481     done
482
483     return "$returnCode"
484 }
485
486 # process uids for the authorized_keys file
487 process_uid_authorized_keys() {
488     local userID
489     local ok
490     local keyid
491     local returnCode
492
493     # default return code is 1, which assumes no key was found
494     returnCode=1
495
496     userID="$1"
497
498     log "processing user ID: $userID"
499
500     for line in $(process_user_id "$userID") ; do
501         ok=$(echo "$line" | cut -d: -f1)
502         keyid=$(echo "$line" | cut -d: -f2)
503
504         sshKey=$(gpg2ssh "$keyid")
505         # remove the old host key line
506         remove_line "$AUTHORIZED_KEYS" "$sshKey"
507         # if key OK, add new host line
508         if [ "$ok" -eq '0' ] ; then
509             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
510
511             # set return code to be 0, since a key was found
512             returnCode=0
513         fi
514     done
515
516     return "$returnCode"
517 }
518
519 # update the authorized_keys files from a list of user IDs on command
520 # line
521 update_authorized_keys() {
522     local userID
523     local returnCode
524
525     # default return code is 0, which assumes a key was found for
526     # every user ID.  code will be set to 1 if a key is not found for
527     # at least one user ID
528     returnCode=0
529
530     # set the trap to remove any lockfiles on exit
531     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
532
533     # create a lockfile on authorized_keys
534     lockfile-create "$AUTHORIZED_KEYS"
535
536     for userID ; do
537         # process the user ID, change return code if key not found for
538         # user ID
539         process_uid_authorized_keys "$userID" || returnCode=1
540
541         # touch the lockfile, for good measure.
542         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
543     done
544
545     # remove the lockfile
546     lockfile-remove "$AUTHORIZED_KEYS"
547
548     return "$returnCode"
549 }
550
551 # process an authorized_user_ids file for authorized_keys
552 process_authorized_user_ids() {
553     local userid
554     local returnCode
555
556     log "processing authorized_user_ids file..."
557
558     # default return code is 0, and is set to 1 if a key for a user ID
559     # is not found
560     returnCode=0
561
562     authorizedUserIDs="$1"
563
564     # set the IFS to be newline for parsing the authorized_user_ids
565     # file.  can't find it in BASH(1) (found it on the net), but it
566     # works.
567     IFS=$'\n'
568     for userid in $(cat "$authorizedUserIDs" | meat) ; do
569         update_authorized_keys "$userid" || returnCode=1
570     done
571
572     return "$returnCode"
573 }
574
575 # EXPERIMENTAL (unused) process userids found in authorized_keys file
576 # go through line-by-line, extract monkeysphere userids from comment
577 # fields, and process each userid
578 # NOT WORKING
579 process_authorized_keys() {
580     local authorizedKeys
581     local userID
582     local returnCode
583
584     # default return code is 0, and is set to 1 if a key for a user
585     # is not found
586     returnCode=0
587
588     authorizedKeys="$1"
589
590     # take all the monkeysphere userids from the authorized_keys file
591     # comment field (third field) that starts with "MonkeySphere uid:"
592     # FIXME: needs to handle authorized_keys options (field 0)
593     cat "$authorizedKeys" | meat | \
594     while read -r options keytype key comment ; do
595         # if the comment field is empty, assume the third field was
596         # the comment
597         if [ -z "$comment" ] ; then
598             comment="$key"
599         fi
600
601         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
602             continue
603         fi
604         userID=$(echo "$comment" | awk "{ print $2 }")
605         if [ -z "$userID" ] ; then
606             continue
607         fi
608
609         # process the userid
610         log "processing userid: '$userID'"
611         process_user_id "$userID" > /dev/null || returnCode=1
612     done
613
614     return "$returnCode"
615 }