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