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