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