More work on priviledge separation for host/authentication keyring.
[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 error() {
24     log "$1"
25     ERR=${2:-'1'}
26 }
27
28 failure() {
29     echo "$1" >&2
30     exit ${2:-'1'}
31 }
32
33 # write output to stderr
34 log() {
35     echo -n "ms: " >&2
36     echo "$@" >&2
37 }
38
39 loge() {
40     echo "$@" >&2
41 }
42
43 # cut out all comments(#) and blank lines from standard input
44 meat() {
45     grep -v -e "^[[:space:]]*#" -e '^$' "$1"
46 }
47
48 # cut a specified line from standard input
49 cutline() {
50     head --line="$1" "$2" | tail -1
51 }
52
53 # check that characters are in a string (in an AND fashion).
54 # used for checking key capability
55 # check_capability capability a [b...]
56 check_capability() {
57     local usage
58     local capcheck
59
60     usage="$1"
61     shift 1
62
63     for capcheck ; do
64         if echo "$usage" | grep -q -v "$capcheck" ; then
65             return 1
66         fi
67     done
68     return 0
69 }
70
71 # convert escaped characters from gpg output back into original
72 # character
73 # FIXME: undo all escape character translation in with-colons gpg output
74 unescape() {
75     echo "$1" | sed 's/\\x3a/:/'
76 }
77
78 # remove all lines with specified string from specified file
79 remove_line() {
80     local file
81     local string
82
83     file="$1"
84     string="$2"
85
86     # if the line is there are removed, return 0
87     if [ "$file" -a "$string" ] ; then
88         grep -v "$string" "$file" | sponge "$file"
89         return 0
90     # otherwise return 1
91     else
92         return 1
93     fi
94 }
95
96 # translate ssh-style path variables %h and %u
97 translate_ssh_variables() {
98     local uname
99     local home
100
101     uname="$1"
102     path="$2"
103
104     # get the user's home directory
105     userHome=$(getent passwd "$uname" | cut -d: -f6)
106
107     # translate '%u' to user name
108     path=${path/\%u/"$uname"}
109     # translate '%h' to user home directory
110     path=${path/\%h/"$userHome"}
111
112     echo "$path"
113 }
114
115 ### CONVERSION UTILITIES
116
117 # output the ssh key for a given key ID
118 gpg2ssh() {
119     local keyID
120     
121     keyID="$1"
122
123     gpg --export "$keyID" | openpgp2ssh "$keyID" 2> /dev/null
124 }
125
126 # output known_hosts line from ssh key
127 ssh2known_hosts() {
128     local host
129     local key
130
131     host="$1"
132     key="$2"
133
134     echo -n "$host "
135     echo -n "$key" | tr -d '\n'
136     echo " MonkeySphere${DATE}"
137 }
138
139 # output authorized_keys line from ssh key
140 ssh2authorized_keys() {
141     local userID
142     local key
143     
144     userID="$1"
145     key="$2"
146
147     echo -n "$key" | tr -d '\n'
148     echo " MonkeySphere${DATE} ${userID}"
149 }
150
151 # convert key from gpg to ssh known_hosts format
152 gpg2known_hosts() {
153     local host
154     local keyID
155
156     host="$1"
157     keyID="$2"
158
159     # NOTE: it seems that ssh-keygen -R removes all comment fields from
160     # all lines in the known_hosts file.  why?
161     # NOTE: just in case, the COMMENT can be matched with the
162     # following regexp:
163     # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
164     echo -n "$host "
165     gpg2ssh "$keyID" | tr -d '\n'
166     echo " MonkeySphere${DATE}"
167 }
168
169 # convert key from gpg to ssh authorized_keys format
170 gpg2authorized_keys() {
171     local userID
172     local keyID
173
174     userID="$1"
175     keyID="$2"
176
177     # NOTE: just in case, the COMMENT can be matched with the
178     # following regexp:
179     # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
180     gpg2ssh "$keyID" | tr -d '\n'
181     echo " MonkeySphere${DATE} ${userID}"
182 }
183
184 ### GPG UTILITIES
185
186 # retrieve all keys with given user id from keyserver
187 # FIXME: need to figure out how to retrieve all matching keys
188 # (not just first N (5 in this case))
189 gpg_fetch_userid() {
190     local userID
191     local returnCode
192
193     if [ "$CHECK_KEYSERVER" != 'true' ] ; then
194         return 0
195     fi
196
197     userID="$1"
198
199     log -n " checking keyserver $KEYSERVER... "
200     echo 1,2,3,4,5 | \
201         gpg --quiet --batch --with-colons \
202         --command-fd 0 --keyserver "$KEYSERVER" \
203         --search ="$userID" > /dev/null 2>&1
204     returnCode="$?"
205     loge "done."
206
207     # if the user is the monkeysphere user, then update the
208     # monkeysphere user's trustdb
209     if [ $(id -un) = "$MONKEYSPHERE_USER" ] ; then
210         gpg_authentication "--check-trustdb" > /dev/null 2>&1
211     fi
212
213     return "$returnCode"
214 }
215
216 ########################################################################
217 ### PROCESSING FUNCTIONS
218
219 # userid and key policy checking
220 # the following checks policy on the returned keys
221 # - checks that full key has appropriate valididy (u|f)
222 # - checks key has specified capability (REQUIRED_*_KEY_CAPABILITY)
223 # - checks that requested user ID has appropriate validity
224 # (see /usr/share/doc/gnupg/DETAILS.gz)
225 # output is one line for every found key, in the following format:
226 #
227 # flag fingerprint
228 #
229 # "flag" is an acceptability flag, 0 = ok, 1 = bad
230 # "fingerprint" is the fingerprint of the key
231 #
232 # expects global variable: "MODE"
233 process_user_id() {
234     local userID
235     local requiredCapability
236     local requiredPubCapability
237     local gpgOut
238     local type
239     local validity
240     local keyid
241     local uidfpr
242     local usage
243     local keyOK
244     local uidOK
245     local lastKey
246     local lastKeyOK
247     local fingerprint
248
249     userID="$1"
250
251     # set the required key capability based on the mode
252     if [ "$MODE" = 'known_hosts' ] ; then
253         requiredCapability="$REQUIRED_HOST_KEY_CAPABILITY"
254     elif [ "$MODE" = 'authorized_keys' ] ; then
255         requiredCapability="$REQUIRED_USER_KEY_CAPABILITY"      
256     fi
257     requiredPubCapability=$(echo "$requiredCapability" | tr "[:lower:]" "[:upper:]")
258
259     # output gpg info for (exact) userid and store
260     gpgOut=$(gpg --list-key --fixed-list-mode --with-colon \
261         --with-fingerprint --with-fingerprint \
262         ="$userID" 2>/dev/null)
263
264     # fetch the user ID if necessary/requested
265     gpg_fetch_userid "$userID"
266
267     # if the gpg query return code is not 0, return 1
268     if [ "$?" -ne 0 ] ; then
269         log "  - key not found."
270         return 1
271     fi
272
273     # loop over all lines in the gpg output and process.
274     # need to do it this way (as opposed to "while read...") so that
275     # variables set in loop will be visible outside of loop
276     echo "$gpgOut" | cut -d: -f1,2,5,10,12 | \
277     while IFS=: read -r type validity keyid uidfpr usage ; do
278         # process based on record type
279         case $type in
280             'pub') # primary keys
281                 # new key, wipe the slate
282                 keyOK=
283                 uidOK=
284                 lastKey=pub
285                 lastKeyOK=
286                 fingerprint=
287
288                 log " primary key found: $keyid"
289
290                 # if overall key is not valid, skip
291                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
292                     log "  - unacceptable primary key validity ($validity)."
293                     continue
294                 fi
295                 # if overall key is disabled, skip
296                 if check_capability "$usage" 'D' ; then
297                     log "  - key disabled."
298                     continue
299                 fi
300                 # if overall key capability is not ok, skip
301                 if ! check_capability "$usage" $requiredPubCapability ; then
302                     log "  - unacceptable primary key capability ($usage)."
303                     continue
304                 fi
305
306                 # mark overall key as ok
307                 keyOK=true
308
309                 # mark primary key as ok if capability is ok
310                 if check_capability "$usage" $requiredCapability ; then
311                     lastKeyOK=true
312                 fi
313                 ;;
314             'uid') # user ids
315                 # if an acceptable user ID was already found, skip
316                 if [ "$uidOK" ] ; then
317                     continue
318                 fi
319                 # if the user ID does not match, skip
320                 if [ "$(unescape "$uidfpr")" != "$userID" ] ; then
321                     continue
322                 fi
323                 # if the user ID validity is not ok, skip
324                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
325                     continue
326                 fi
327
328                 # mark user ID acceptable
329                 uidOK=true
330
331                 # output a line for the primary key
332                 # 0 = ok, 1 = bad
333                 if [ "$keyOK" -a "$uidOK" -a "$lastKeyOK" ] ; then
334                     log "  * acceptable key found."
335                     echo "0:${fingerprint}"
336                 else
337                     echo "1:${fingerprint}"
338                 fi
339                 ;;
340             'sub') # sub keys
341                 # unset acceptability of last key
342                 lastKey=sub
343                 lastKeyOK=
344                 fingerprint=
345
346                 # if sub key validity is not ok, skip
347                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
348                     continue
349                 fi
350                 # if sub key capability is not ok, skip
351                 if ! check_capability "$usage" $requiredCapability ; then
352                     continue
353                 fi
354
355                 # mark sub key as ok
356                 lastKeyOK=true
357                 ;;
358             'fpr') # key fingerprint
359                 fingerprint="$uidfpr"
360
361                 # if the last key was the pub key, skip
362                 if [ "$lastKey" = pub ] ; then
363                     continue
364                 fi
365                 
366                 # output a line for the last subkey
367                 # 0 = ok, 1 = bad
368                 if [ "$keyOK" -a "$uidOK" -a "$lastKeyOK" ] ; then
369                     log "  * acceptable key found."
370                     echo "0:${fingerprint}"
371                 else
372                     echo "1:${fingerprint}"
373                 fi
374                 ;;
375         esac
376     done
377 }
378
379 # process a single host in the known_host file
380 process_host_known_hosts() {
381     local host
382     local userID
383     local ok
384     local keyid
385     local idOK
386     local idRemoved
387     local tmpfile
388
389     host="$1"
390
391     log "processing host: $host"
392
393     userID="ssh://${host}"
394
395     for line in $(process_user_id "ssh://${host}") ; do
396         ok=$(echo "$line" | cut -d: -f1)
397         keyid=$(echo "$line" | cut -d: -f2)
398
399         sshKey=$(gpg2ssh "$keyid")
400
401         # remove the old host key line, and note if removed
402         remove_line "$KNOWN_HOSTS" "$sshKey" && idRemoved=true
403
404         # if key OK, add new host line
405         if [ "$ok" -eq '0' ] ; then
406             # hash if specified
407             if [ "$HASH_KNOWN_HOSTS" = 'true' ] ; then
408                 # FIXME: this is really hackish cause ssh-keygen won't
409                 # hash from stdin to stdout
410                 tmpfile=$(mktemp)
411                 ssh2known_hosts "$host" "$sshKey" > "$tmpfile"
412                 ssh-keygen -H -f "$tmpfile" 2> /dev/null
413                 cat "$tmpfile" >> "$KNOWN_HOSTS"
414                 rm -f "$tmpfile" "${tmpfile}.old"
415             else
416                 ssh2known_hosts "$host" "$sshKey" >> "$KNOWN_HOSTS"
417             fi
418
419             # note that at least one ok id was found
420             idOK=true
421         fi
422     done
423
424     # if at least one ok id was found, return 0
425     if [ "$idOK" ] ; then
426         return 0
427
428     # if ids were only removed, return 2
429     elif [ "$idRemoved" ] ; then
430         return 2
431
432     # else return 1, to indicate nothing happened
433     else
434         return 1
435     fi
436 }
437
438 # update the known_hosts file for a set of hosts listed on command
439 # line
440 update_known_hosts() {
441     local nHosts
442     local host
443     local nHostsOK
444     local nHostsBAD
445
446     # the number of hosts specified on command line
447     nHosts="$#"
448
449     nHostsOK=0
450     nHostsBAD=0
451
452     # set the trap to remove any lockfiles on exit
453     trap "lockfile-remove $KNOWN_HOSTS" EXIT
454
455     # create a lockfile on known_hosts
456     lockfile-create "$KNOWN_HOSTS"
457
458     for host ; do
459         # process the host
460         process_host_known_hosts "$host"
461         # note the result
462         case "$?" in
463             0)
464                 nHostsOK=$((nHostsOK+1))
465                 ;;
466             2)
467                 nHostsBAD=$((nHostsBAD+1))
468                 ;;
469         esac
470
471         # touch the lockfile, for good measure.
472         lockfile-touch --oneshot "$KNOWN_HOSTS"
473     done
474
475     # remove the lockfile
476     lockfile-remove "$KNOWN_HOSTS"
477
478     # note if the known_hosts file was updated
479     if [ "$nHostsOK" -gt 0 -o "$nHostsBAD" -gt 0 ] ; then
480         log "known_hosts file updated."
481     fi
482
483     # if all hosts were OK, return 0
484     if [ "$nHostsOK" -eq "$nHosts" ] ; then
485         return 0
486
487     # if all hosts were BAD, return 2
488     elif [ "$nHostsBAD" -eq "$nHosts" ] ; then
489         return 2
490
491     # else return 1
492     else
493         return 1
494     fi
495 }
496
497 # process hosts from a known_hosts file
498 process_known_hosts() {
499     local hosts
500
501     log "processing known_hosts file..."
502
503     hosts=$(meat "$KNOWN_HOSTS" | cut -d ' ' -f 1 | grep -v '^|.*$' | tr , ' ' | tr '\n' ' ')
504
505     # take all the hosts from the known_hosts file (first
506     # field), grep out all the hashed hosts (lines starting
507     # with '|')...
508     update_known_hosts $hosts
509 }
510
511 # process uids for the authorized_keys file
512 process_uid_authorized_keys() {
513     local userID
514     local ok
515     local keyid
516     local idOK
517     local idRemoved
518
519     userID="$1"
520
521     log "processing user ID: $userID"
522
523     for line in $(process_user_id "$userID") ; do
524         ok=$(echo "$line" | cut -d: -f1)
525         keyid=$(echo "$line" | cut -d: -f2)
526
527         sshKey=$(gpg2ssh "$keyid")
528
529         # remove the old host key line
530         remove_line "$AUTHORIZED_KEYS" "$sshKey" && idRemoved=true
531
532         # if key OK, add new host line
533         if [ "$ok" -eq '0' ] ; then
534             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
535
536             # note that at least one ok id was found
537             idOK=true
538         fi
539     done
540
541     # if at least one ok id was found, return 0
542     if [ "$idOK" ] ; then
543         return 0
544
545     # if ids were only removed, return 2
546     elif [ "$idRemoved" ] ; then
547         return 2
548
549     # else return 1, to indicate nothing happened
550     else
551         return 1
552     fi
553 }
554
555 # update the authorized_keys files from a list of user IDs on command
556 # line
557 update_authorized_keys() {
558     local userID
559     local nIDs
560     local nIDsOK
561     local nIDsBAD
562
563     # the number of ids specified on command line
564     nIDs="$#"
565
566     nIDsOK=0
567     nIDsBAD=0
568
569     # set the trap to remove any lockfiles on exit
570     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
571
572     # create a lockfile on authorized_keys
573     lockfile-create "$AUTHORIZED_KEYS"
574
575     for userID ; do
576         # process the user ID, change return code if key not found for
577         # user ID
578         process_uid_authorized_keys "$userID"
579
580         # note the result
581         case "$?" in
582             0)
583                 nIDsOK=$((nIDsOK+1))
584                 ;;
585             2)
586                 nIDsBAD=$((nIDsBAD+1))
587                 ;;
588         esac
589
590         # touch the lockfile, for good measure.
591         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
592     done
593
594     # remove the lockfile
595     lockfile-remove "$AUTHORIZED_KEYS"
596
597     # note if the authorized_keys file was updated
598     if [ "$nIDsOK" -gt 0 -o "$nIDsBAD" -gt 0 ] ; then
599         log "authorized_keys file updated."
600     fi
601
602     # if all ids were OK, return 0
603     if [ "$nIDsOK" -eq "$nIDs" ] ; then
604         return 0
605
606     # if all ids were BAD, return 2
607     elif [ "$nIDsBAD" -eq "$nIDs" ] ; then
608         return 2
609
610     # else return 1
611     else
612         return 1
613     fi
614 }
615
616 # process an authorized_user_ids file for authorized_keys
617 process_authorized_user_ids() {
618     local line
619     local userIDs
620
621     authorizedUserIDs="$1"
622
623     log "processing authorized_user_ids file..."
624
625     # extract user IDs from authorized_user_ids file
626     for line in $(seq 1 $(meat "$authorizedUserIDs" | wc -l)) ; do
627         userIDs[$((line-1))]=$(cutline "$line" "$authorizedUserIDs")
628     done
629
630     update_authorized_keys "${userIDs[@]}"
631 }
632
633 # EXPERIMENTAL (unused) process userids found in authorized_keys file
634 # go through line-by-line, extract monkeysphere userids from comment
635 # fields, and process each userid
636 # NOT WORKING
637 process_authorized_keys() {
638     local authorizedKeys
639     local userID
640     local returnCode
641
642     # default return code is 0, and is set to 1 if a key for a user
643     # is not found
644     returnCode=0
645
646     authorizedKeys="$1"
647
648     # take all the monkeysphere userids from the authorized_keys file
649     # comment field (third field) that starts with "MonkeySphere uid:"
650     # FIXME: needs to handle authorized_keys options (field 0)
651     meat "$authorizedKeys" | \
652     while read -r options keytype key comment ; do
653         # if the comment field is empty, assume the third field was
654         # the comment
655         if [ -z "$comment" ] ; then
656             comment="$key"
657         fi
658
659         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
660             continue
661         fi
662         userID=$(echo "$comment" | awk "{ print $2 }")
663         if [ -z "$userID" ] ; then
664             continue
665         fi
666
667         # process the userid
668         log "processing userid: '$userID'"
669         process_user_id "$userID" > /dev/null || returnCode=1
670     done
671
672     return "$returnCode"
673 }