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