Merge commit 'jrollins/master'
[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 "  - key not 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 key found."
381                     echo "0:${fingerprint}"
382                 else
383                     echo "1:${fingerprint}"
384                 fi
385                 ;;
386             'sub') # sub keys
387                 # unset acceptability of last key
388                 lastKey=sub
389                 lastKeyOK=
390                 fingerprint=
391
392                 # if sub key validity is not ok, skip
393                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
394                     continue
395                 fi
396                 # if sub key capability is not ok, skip
397                 if ! check_capability "$usage" $requiredCapability ; then
398                     continue
399                 fi
400
401                 # mark sub key as ok
402                 lastKeyOK=true
403                 ;;
404             'fpr') # key fingerprint
405                 fingerprint="$uidfpr"
406
407                 # if the last key was the pub key, skip
408                 if [ "$lastKey" = pub ] ; then
409                     continue
410                 fi
411                 
412                 # output a line for the last subkey
413                 # 0 = ok, 1 = bad
414                 if [ "$keyOK" -a "$uidOK" -a "$lastKeyOK" ] ; then
415                     log "  * acceptable key found."
416                     echo "0:${fingerprint}"
417                 else
418                     echo "1:${fingerprint}"
419                 fi
420                 ;;
421         esac
422     done
423 }
424
425 # process a single host in the known_host file
426 process_host_known_hosts() {
427     local host
428     local userID
429     local nKeys
430     local nKeysOK
431     local ok
432     local keyid
433     local tmpfile
434
435     host="$1"
436
437     log "processing host: $host"
438
439     userID="ssh://${host}"
440
441     nKeys=0
442     nKeysOK=0
443
444     for line in $(process_user_id "ssh://${host}") ; do
445         # note that key was found
446         nKeys=$((nKeys+1))
447
448         ok=$(echo "$line" | cut -d: -f1)
449         keyid=$(echo "$line" | cut -d: -f2)
450
451         sshKey=$(gpg2ssh "$keyid")
452         if [ -z "$sshKey" ] ; then
453             log "  ! key could not be translated."
454             continue
455         fi
456
457         # remove the old host key line, and note if removed
458         remove_line "$KNOWN_HOSTS" "$sshKey"
459
460         # if key OK, add new host line
461         if [ "$ok" -eq '0' ] ; then
462             # note that key was found ok
463             nKeysOK=$((nKeysOK+1))
464
465             # hash if specified
466             if [ "$HASH_KNOWN_HOSTS" = 'true' ] ; then
467                 # FIXME: this is really hackish cause ssh-keygen won't
468                 # hash from stdin to stdout
469                 tmpfile=$(mktemp)
470                 ssh2known_hosts "$host" "$sshKey" > "$tmpfile"
471                 ssh-keygen -H -f "$tmpfile" 2> /dev/null
472                 cat "$tmpfile" >> "$KNOWN_HOSTS"
473                 rm -f "$tmpfile" "${tmpfile}.old"
474             else
475                 ssh2known_hosts "$host" "$sshKey" >> "$KNOWN_HOSTS"
476             fi
477         fi
478     done
479
480     # if at least one key was found...
481     if [ "$nKeys" -gt 0 ] ; then
482         # if ok keys were found, return 0
483         if [ "$nKeysOK" -gt 0 ] ; then
484             return 0
485         # else return 2
486         else
487             return 2
488         fi
489     # if no keys were found, return 1
490     else
491         return 1
492     fi
493 }
494
495 # update the known_hosts file for a set of hosts listed on command
496 # line
497 update_known_hosts() {
498     local nHosts
499     local nHostsOK
500     local nHostsBAD
501     local host
502
503     # the number of hosts specified on command line
504     nHosts="$#"
505
506     nHostsOK=0
507     nHostsBAD=0
508
509     # set the trap to remove any lockfiles on exit
510     trap "lockfile-remove $KNOWN_HOSTS" EXIT
511
512     # create a lockfile on known_hosts
513     lockfile-create "$KNOWN_HOSTS"
514
515     for host ; do
516         # process the host
517         process_host_known_hosts "$host"
518         # note the result
519         case "$?" in
520             0)
521                 nHostsOK=$((nHostsOK+1))
522                 ;;
523             2)
524                 nHostsBAD=$((nHostsBAD+1))
525                 ;;
526         esac
527
528         # touch the lockfile, for good measure.
529         lockfile-touch --oneshot "$KNOWN_HOSTS"
530     done
531
532     # remove the lockfile
533     lockfile-remove "$KNOWN_HOSTS"
534
535     # note if the known_hosts file was updated
536     if [ "$nHostsOK" -gt 0 -o "$nHostsBAD" -gt 0 ] ; then
537         log "known_hosts file updated."
538     fi
539
540     # if an acceptable host was found, return 0
541     if [ "$nHostsOK" -gt 0 ] ; then
542         return 0
543     # else if no ok hosts were found...
544     else
545         # if no bad host were found then no hosts were found at all,
546         # and return 1
547         if [ "$nHostsBAD" -eq 0 ] ; then
548             return 1
549         # else if at least one bad host was found, return 2
550         else
551             return 2
552         fi
553     fi
554 }
555
556 # process hosts from a known_hosts file
557 process_known_hosts() {
558     local hosts
559
560     log "processing known_hosts file..."
561
562     hosts=$(meat "$KNOWN_HOSTS" | cut -d ' ' -f 1 | grep -v '^|.*$' | tr , ' ' | tr '\n' ' ')
563
564     if [ -z "$hosts" ] ; then
565         log "no hosts to process."
566         return
567     fi
568
569     # take all the hosts from the known_hosts file (first
570     # field), grep out all the hashed hosts (lines starting
571     # with '|')...
572     update_known_hosts $hosts
573 }
574
575 # process uids for the authorized_keys file
576 process_uid_authorized_keys() {
577     local userID
578     local nKeys
579     local nKeysOK
580     local ok
581     local keyid
582
583     userID="$1"
584
585     log "processing user ID: $userID"
586
587     nKeys=0
588     nKeysOK=0
589
590     for line in $(process_user_id "$userID") ; do
591         # note that key was found
592         nKeys=$((nKeys+1))
593
594         ok=$(echo "$line" | cut -d: -f1)
595         keyid=$(echo "$line" | cut -d: -f2)
596
597         sshKey=$(gpg2ssh "$keyid")
598         if [ -z "$sshKey" ] ; then
599             log "  ! key could not be translated."
600             continue
601         fi
602
603         # remove the old host key line
604         remove_line "$AUTHORIZED_KEYS" "$sshKey"
605
606         # if key OK, add new host line
607         if [ "$ok" -eq '0' ] ; then
608             # note that key was found ok
609             nKeysOK=$((nKeysOK+1))
610
611             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
612         fi
613     done
614
615     # if at least one key was found...
616     if [ "$nKeys" -gt 0 ] ; then
617         # if ok keys were found, return 0
618         if [ "$nKeysOK" -gt 0 ] ; then
619             return 0
620         # else return 2
621         else
622             return 2
623         fi
624     # if no keys were found, return 1
625     else
626         return 1
627     fi
628 }
629
630 # update the authorized_keys files from a list of user IDs on command
631 # line
632 update_authorized_keys() {
633     local userID
634     local nIDs
635     local nIDsOK
636     local nIDsBAD
637
638     # the number of ids specified on command line
639     nIDs="$#"
640
641     nIDsOK=0
642     nIDsBAD=0
643
644     # set the trap to remove any lockfiles on exit
645     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
646
647     # create a lockfile on authorized_keys
648     lockfile-create "$AUTHORIZED_KEYS"
649
650     for userID ; do
651         # process the user ID, change return code if key not found for
652         # user ID
653         process_uid_authorized_keys "$userID"
654
655         # note the result
656         case "$?" in
657             0)
658                 nIDsOK=$((nIDsOK+1))
659                 ;;
660             2)
661                 nIDsBAD=$((nIDsBAD+1))
662                 ;;
663         esac
664
665         # touch the lockfile, for good measure.
666         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
667     done
668
669     # remove the lockfile
670     lockfile-remove "$AUTHORIZED_KEYS"
671
672     # note if the authorized_keys file was updated
673     if [ "$nIDsOK" -gt 0 -o "$nIDsBAD" -gt 0 ] ; then
674         log "authorized_keys file updated."
675     fi
676
677     # if an acceptable id was found, return 0
678     if [ "$nIDsOK" -gt 0 ] ; then
679         return 0
680     # else if no ok ids were found...
681     else
682         # if no bad ids were found then no ids were found at all, and
683         # return 1
684         if [ "$nIDsBAD" -eq 0 ] ; then
685             return 1
686         # else if at least one bad id was found, return 2
687         else
688             return 2
689         fi
690     fi
691 }
692
693 # process an authorized_user_ids file for authorized_keys
694 process_authorized_user_ids() {
695     local line
696     local nline
697     local userIDs
698
699     authorizedUserIDs="$1"
700
701     log "processing authorized_user_ids file..."
702
703     if ! meat "$authorizedUserIDs" ; then
704         log "no user IDs to process."
705         return
706     fi
707
708     nline=0
709
710     # extract user IDs from authorized_user_ids file
711     IFS=$'\n'
712     for line in $(meat "$authorizedUserIDs") ; do
713         userIDs["$nline"]="$line"
714         nline=$((nline+1))
715     done
716
717     update_authorized_keys "${userIDs[@]}"
718 }
719
720 # EXPERIMENTAL (unused) process userids found in authorized_keys file
721 # go through line-by-line, extract monkeysphere userids from comment
722 # fields, and process each userid
723 # NOT WORKING
724 process_authorized_keys() {
725     local authorizedKeys
726     local userID
727     local returnCode
728
729     # default return code is 0, and is set to 1 if a key for a user
730     # is not found
731     returnCode=0
732
733     authorizedKeys="$1"
734
735     # take all the monkeysphere userids from the authorized_keys file
736     # comment field (third field) that starts with "MonkeySphere uid:"
737     # FIXME: needs to handle authorized_keys options (field 0)
738     meat "$authorizedKeys" | \
739     while read -r options keytype key comment ; do
740         # if the comment field is empty, assume the third field was
741         # the comment
742         if [ -z "$comment" ] ; then
743             comment="$key"
744         fi
745
746         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
747             continue
748         fi
749         userID=$(echo "$comment" | awk "{ print $2 }")
750         if [ -z "$userID" ] ; then
751             continue
752         fi
753
754         # process the userid
755         log "processing userid: '$userID'"
756         process_user_id "$userID" > /dev/null || returnCode=1
757     done
758
759     return "$returnCode"
760 }