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