Add sorting of the processed key lines so that "good" keys are output
[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 | sort -t: -k1 -n -r
488     # NOTE: this last sort is important so that the "good" keys (key
489     # flag '0') come last.  This is so that they take precedence when
490     # being processed in the key files over "bad" keys (key flag '1')
491 }
492
493 # process a single host in the known_host file
494 process_host_known_hosts() {
495     local host
496     local userID
497     local nKeys
498     local nKeysOK
499     local ok
500     local sshKey
501     local tmpfile
502
503     host="$1"
504     userID="ssh://${host}"
505
506     log "processing: $host"
507
508     nKeys=0
509     nKeysOK=0
510
511     IFS=$'\n'
512     for line in $(process_user_id "${userID}") ; do
513         # note that key was found
514         nKeys=$((nKeys+1))
515
516         ok=$(echo "$line" | cut -d: -f1)
517         sshKey=$(echo "$line" | cut -d: -f2)
518
519         if [ -z "$sshKey" ] ; then
520             continue
521         fi
522
523         # remove the old host key line, and note if removed
524         remove_line "$KNOWN_HOSTS" "$sshKey"
525
526         # if key OK, add new host line
527         if [ "$ok" -eq '0' ] ; then
528             # note that key was found ok
529             nKeysOK=$((nKeysOK+1))
530
531             # hash if specified
532             if [ "$HASH_KNOWN_HOSTS" = 'true' ] ; then
533                 # FIXME: this is really hackish cause ssh-keygen won't
534                 # hash from stdin to stdout
535                 tmpfile=$(mktemp)
536                 ssh2known_hosts "$host" "$sshKey" > "$tmpfile"
537                 ssh-keygen -H -f "$tmpfile" 2> /dev/null
538                 cat "$tmpfile" >> "$KNOWN_HOSTS"
539                 rm -f "$tmpfile" "${tmpfile}.old"
540             else
541                 ssh2known_hosts "$host" "$sshKey" >> "$KNOWN_HOSTS"
542             fi
543         fi
544     done
545
546     # if at least one key was found...
547     if [ "$nKeys" -gt 0 ] ; then
548         # if ok keys were found, return 0
549         if [ "$nKeysOK" -gt 0 ] ; then
550             return 0
551         # else return 2
552         else
553             return 2
554         fi
555     # if no keys were found, return 1
556     else
557         return 1
558     fi
559 }
560
561 # update the known_hosts file for a set of hosts listed on command
562 # line
563 update_known_hosts() {
564     local nHosts
565     local nHostsOK
566     local nHostsBAD
567     local fileCheck
568     local host
569
570     # the number of hosts specified on command line
571     nHosts="$#"
572
573     nHostsOK=0
574     nHostsBAD=0
575
576     # set the trap to remove any lockfiles on exit
577     trap "lockfile-remove $KNOWN_HOSTS" EXIT
578
579     # create a lockfile on known_hosts
580     lockfile-create "$KNOWN_HOSTS"
581
582     # note pre update file checksum
583     fileCheck="$(file_hash "$KNOWN_HOSTS")"
584
585     for host ; do
586         # process the host
587         process_host_known_hosts "$host"
588         # note the result
589         case "$?" in
590             0)
591                 nHostsOK=$((nHostsOK+1))
592                 ;;
593             2)
594                 nHostsBAD=$((nHostsBAD+1))
595                 ;;
596         esac
597
598         # touch the lockfile, for good measure.
599         lockfile-touch --oneshot "$KNOWN_HOSTS"
600     done
601
602     # remove the lockfile
603     lockfile-remove "$KNOWN_HOSTS"
604
605     # note if the known_hosts file was updated
606     if [ "$(file_hash "$KNOWN_HOSTS")" != "$fileCheck" ] ; then
607         log "known_hosts file updated."
608     fi
609
610     # if an acceptable host was found, return 0
611     if [ "$nHostsOK" -gt 0 ] ; then
612         return 0
613     # else if no ok hosts were found...
614     else
615         # if no bad host were found then no hosts were found at all,
616         # and return 1
617         if [ "$nHostsBAD" -eq 0 ] ; then
618             return 1
619         # else if at least one bad host was found, return 2
620         else
621             return 2
622         fi
623     fi
624 }
625
626 # process hosts from a known_hosts file
627 process_known_hosts() {
628     local hosts
629
630     log "processing known_hosts file..."
631
632     hosts=$(meat "$KNOWN_HOSTS" | cut -d ' ' -f 1 | grep -v '^|.*$' | tr , ' ' | tr '\n' ' ')
633
634     if [ -z "$hosts" ] ; then
635         log "no hosts to process."
636         return
637     fi
638
639     # take all the hosts from the known_hosts file (first
640     # field), grep out all the hashed hosts (lines starting
641     # with '|')...
642     update_known_hosts $hosts
643 }
644
645 # process uids for the authorized_keys file
646 process_uid_authorized_keys() {
647     local userID
648     local nKeys
649     local nKeysOK
650     local ok
651     local sshKey
652
653     userID="$1"
654
655     log "processing: $userID"
656
657     nKeys=0
658     nKeysOK=0
659
660     IFS=$'\n'
661     for line in $(process_user_id "$userID") ; do
662         # note that key was found
663         nKeys=$((nKeys+1))
664
665         ok=$(echo "$line" | cut -d: -f1)
666         sshKey=$(echo "$line" | cut -d: -f2)
667
668         if [ -z "$sshKey" ] ; then
669             continue
670         fi
671
672         # remove the old host key line
673         remove_line "$AUTHORIZED_KEYS" "$sshKey"
674
675         # if key OK, add new host line
676         if [ "$ok" -eq '0' ] ; then
677             # note that key was found ok
678             nKeysOK=$((nKeysOK+1))
679
680             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
681         fi
682     done
683
684     # if at least one key was found...
685     if [ "$nKeys" -gt 0 ] ; then
686         # if ok keys were found, return 0
687         if [ "$nKeysOK" -gt 0 ] ; then
688             return 0
689         # else return 2
690         else
691             return 2
692         fi
693     # if no keys were found, return 1
694     else
695         return 1
696     fi
697 }
698
699 # update the authorized_keys files from a list of user IDs on command
700 # line
701 update_authorized_keys() {
702     local userID
703     local nIDs
704     local nIDsOK
705     local nIDsBAD
706     local fileCheck
707
708     # the number of ids specified on command line
709     nIDs="$#"
710
711     nIDsOK=0
712     nIDsBAD=0
713
714     # set the trap to remove any lockfiles on exit
715     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
716
717     # create a lockfile on authorized_keys
718     lockfile-create "$AUTHORIZED_KEYS"
719
720     # note pre update file checksum
721     fileCheck="$(file_hash "$AUTHORIZED_KEYS")"
722
723     # remove any monkeysphere lines from authorized_keys file
724     remove_monkeysphere_lines "$AUTHORIZED_KEYS"
725
726     for userID ; do
727         # process the user ID, change return code if key not found for
728         # user ID
729         process_uid_authorized_keys "$userID"
730
731         # note the result
732         case "$?" in
733             0)
734                 nIDsOK=$((nIDsOK+1))
735                 ;;
736             2)
737                 nIDsBAD=$((nIDsBAD+1))
738                 ;;
739         esac
740
741         # touch the lockfile, for good measure.
742         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
743     done
744
745     # remove the lockfile
746     lockfile-remove "$AUTHORIZED_KEYS"
747
748     # note if the authorized_keys file was updated
749     if [ "$(file_hash "$AUTHORIZED_KEYS")" != "$fileCheck" ] ; then
750         log "authorized_keys file updated."
751     fi
752
753     # if an acceptable id was found, return 0
754     if [ "$nIDsOK" -gt 0 ] ; then
755         return 0
756     # else if no ok ids were found...
757     else
758         # if no bad ids were found then no ids were found at all, and
759         # return 1
760         if [ "$nIDsBAD" -eq 0 ] ; then
761             return 1
762         # else if at least one bad id was found, return 2
763         else
764             return 2
765         fi
766     fi
767 }
768
769 # process an authorized_user_ids file for authorized_keys
770 process_authorized_user_ids() {
771     local line
772     local nline
773     local userIDs
774
775     authorizedUserIDs="$1"
776
777     log "processing authorized_user_ids file..."
778
779     if ! meat "$authorizedUserIDs" > /dev/null ; then
780         log "no user IDs to process."
781         return
782     fi
783
784     nline=0
785
786     # extract user IDs from authorized_user_ids file
787     IFS=$'\n'
788     for line in $(meat "$authorizedUserIDs") ; do
789         userIDs["$nline"]="$line"
790         nline=$((nline+1))
791     done
792
793     update_authorized_keys "${userIDs[@]}"
794 }