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