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