ba7df7320f69275f81e1d80cae4892649f95fcf2
[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 fileCheck
502     local host
503
504     # the number of hosts specified on command line
505     nHosts="$#"
506
507     nHostsOK=0
508     nHostsBAD=0
509
510     # set the trap to remove any lockfiles on exit
511     trap "lockfile-remove $KNOWN_HOSTS" EXIT
512
513     # create a lockfile on known_hosts
514     lockfile-create "$KNOWN_HOSTS"
515
516     # note pre update file checksum
517     fileCheck=$(md5sum "$KNOWN_HOSTS")
518
519     for host ; do
520         # process the host
521         process_host_known_hosts "$host"
522         # note the result
523         case "$?" in
524             0)
525                 nHostsOK=$((nHostsOK+1))
526                 ;;
527             2)
528                 nHostsBAD=$((nHostsBAD+1))
529                 ;;
530         esac
531
532         # touch the lockfile, for good measure.
533         lockfile-touch --oneshot "$KNOWN_HOSTS"
534     done
535
536     # remove the lockfile
537     lockfile-remove "$KNOWN_HOSTS"
538
539     # note if the known_hosts file was updated
540     if [ "$(md5sum "$KNOWN_HOSTS")" != "$fileCheck" ] ; then
541         log "known_hosts file updated."
542     fi
543
544     # if an acceptable host was found, return 0
545     if [ "$nHostsOK" -gt 0 ] ; then
546         return 0
547     # else if no ok hosts were found...
548     else
549         # if no bad host were found then no hosts were found at all,
550         # and return 1
551         if [ "$nHostsBAD" -eq 0 ] ; then
552             return 1
553         # else if at least one bad host was found, return 2
554         else
555             return 2
556         fi
557     fi
558 }
559
560 # process hosts from a known_hosts file
561 process_known_hosts() {
562     local hosts
563
564     log "processing known_hosts file..."
565
566     hosts=$(meat "$KNOWN_HOSTS" | cut -d ' ' -f 1 | grep -v '^|.*$' | tr , ' ' | tr '\n' ' ')
567
568     if [ -z "$hosts" ] ; then
569         log "no hosts to process."
570         return
571     fi
572
573     # take all the hosts from the known_hosts file (first
574     # field), grep out all the hashed hosts (lines starting
575     # with '|')...
576     update_known_hosts $hosts
577 }
578
579 # process uids for the authorized_keys file
580 process_uid_authorized_keys() {
581     local userID
582     local nKeys
583     local nKeysOK
584     local ok
585     local keyid
586
587     userID="$1"
588
589     log "processing user ID: $userID"
590
591     nKeys=0
592     nKeysOK=0
593
594     for line in $(process_user_id "$userID") ; do
595         # note that key was found
596         nKeys=$((nKeys+1))
597
598         ok=$(echo "$line" | cut -d: -f1)
599         keyid=$(echo "$line" | cut -d: -f2)
600
601         sshKey=$(gpg2ssh "$keyid")
602         if [ -z "$sshKey" ] ; then
603             log "  ! key could not be translated."
604             continue
605         fi
606
607         # remove the old host key line
608         remove_line "$AUTHORIZED_KEYS" "$sshKey"
609
610         # if key OK, add new host line
611         if [ "$ok" -eq '0' ] ; then
612             # note that key was found ok
613             nKeysOK=$((nKeysOK+1))
614
615             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
616         fi
617     done
618
619     # if at least one key was found...
620     if [ "$nKeys" -gt 0 ] ; then
621         # if ok keys were found, return 0
622         if [ "$nKeysOK" -gt 0 ] ; then
623             return 0
624         # else return 2
625         else
626             return 2
627         fi
628     # if no keys were found, return 1
629     else
630         return 1
631     fi
632 }
633
634 # update the authorized_keys files from a list of user IDs on command
635 # line
636 update_authorized_keys() {
637     local userID
638     local nIDs
639     local nIDsOK
640     local nIDsBAD
641     local fileCheck
642
643     # the number of ids specified on command line
644     nIDs="$#"
645
646     nIDsOK=0
647     nIDsBAD=0
648
649     # set the trap to remove any lockfiles on exit
650     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
651
652     # create a lockfile on authorized_keys
653     lockfile-create "$AUTHORIZED_KEYS"
654
655     # note pre update file checksum
656     fileCheck=$(md5sum "$AUTHORIZED_KEYS")
657
658     for userID ; do
659         # process the user ID, change return code if key not found for
660         # user ID
661         process_uid_authorized_keys "$userID"
662
663         # note the result
664         case "$?" in
665             0)
666                 nIDsOK=$((nIDsOK+1))
667                 ;;
668             2)
669                 nIDsBAD=$((nIDsBAD+1))
670                 ;;
671         esac
672
673         # touch the lockfile, for good measure.
674         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
675     done
676
677     # remove the lockfile
678     lockfile-remove "$AUTHORIZED_KEYS"
679
680     # note if the authorized_keys file was updated
681     if [ "$(md5sum "$AUTHORIZED_KEYS")" != "$fileCheck" ] ; then
682         log "authorized_keys file updated."
683     fi
684
685     # if an acceptable id was found, return 0
686     if [ "$nIDsOK" -gt 0 ] ; then
687         return 0
688     # else if no ok ids were found...
689     else
690         # if no bad ids were found then no ids were found at all, and
691         # return 1
692         if [ "$nIDsBAD" -eq 0 ] ; then
693             return 1
694         # else if at least one bad id was found, return 2
695         else
696             return 2
697         fi
698     fi
699 }
700
701 # process an authorized_user_ids file for authorized_keys
702 process_authorized_user_ids() {
703     local line
704     local nline
705     local userIDs
706
707     authorizedUserIDs="$1"
708
709     log "processing authorized_user_ids file..."
710
711     if ! meat "$authorizedUserIDs" > /dev/null ; then
712         log "no user IDs to process."
713         return
714     fi
715
716     nline=0
717
718     # extract user IDs from authorized_user_ids file
719     IFS=$'\n'
720     for line in $(meat "$authorizedUserIDs") ; do
721         userIDs["$nline"]="$line"
722         nline=$((nline+1))
723     done
724
725     update_authorized_keys "${userIDs[@]}"
726 }
727
728 # EXPERIMENTAL (unused) process userids found in authorized_keys file
729 # go through line-by-line, extract monkeysphere userids from comment
730 # fields, and process each userid
731 # NOT WORKING
732 process_authorized_keys() {
733     local authorizedKeys
734     local userID
735     local returnCode
736
737     # default return code is 0, and is set to 1 if a key for a user
738     # is not found
739     returnCode=0
740
741     authorizedKeys="$1"
742
743     # take all the monkeysphere userids from the authorized_keys file
744     # comment field (third field) that starts with "MonkeySphere uid:"
745     # FIXME: needs to handle authorized_keys options (field 0)
746     meat "$authorizedKeys" | \
747     while read -r options keytype key comment ; do
748         # if the comment field is empty, assume the third field was
749         # the comment
750         if [ -z "$comment" ] ; then
751             comment="$key"
752         fi
753
754         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
755             continue
756         fi
757         userID=$(echo "$comment" | awk "{ print $2 }")
758         if [ -z "$userID" ] ; then
759             continue
760         fi
761
762         # process the userid
763         log "processing userid: '$userID'"
764         process_user_id "$userID" > /dev/null || returnCode=1
765     done
766
767     return "$returnCode"
768 }