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