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