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