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