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