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