Revert to simpler keyserver checking policy in proxy-command.
[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     # set the trap to remove any lockfiles on exit
449     trap "lockfile-remove $KNOWN_HOSTS" EXIT
450
451     # create a lockfile on known_hosts
452     lockfile-create "$KNOWN_HOSTS"
453
454     for host ; do
455         # process the host, change return code if host key not found
456         process_host_known_hosts "$host" || returnCode=1
457         
458         # touch the lockfile, for good measure.
459         lockfile-touch --oneshot "$KNOWN_HOSTS"
460     done
461
462     # remove the lockfile
463     lockfile-remove "$KNOWN_HOSTS"
464
465     return "$returnCode"
466 }
467
468 # process known_hosts file, going through line-by-line, extract each
469 # host, and process with the host processing function
470 process_known_hosts() {
471     local returnCode
472
473     # default return code is 0, which assumes a key was found for
474     # every host.  code will be set to 1 if a key is not found for at
475     # least one host
476     returnCode=0
477
478     # take all the hosts from the known_hosts file (first field), grep
479     # out all the hashed hosts (lines starting with '|')...
480     for line in $(cat "$KNOWN_HOSTS" | meat | cut -d ' ' -f 1 | grep -v '^|.*$') ; do
481         # break up hosts into separate words
482         update_known_hosts $(echo "$line" | tr , ' ') || returnCode=1
483     done
484
485     return "$returnCode"
486 }
487
488 # process uids for the authorized_keys file
489 process_uid_authorized_keys() {
490     local userID
491     local ok
492     local keyid
493     local returnCode
494
495     # default return code is 1, which assumes no key was found
496     returnCode=1
497
498     userID="$1"
499
500     log "processing user ID: $userID"
501
502     for line in $(process_user_id "$userID") ; do
503         ok=$(echo "$line" | cut -d: -f1)
504         keyid=$(echo "$line" | cut -d: -f2)
505
506         sshKey=$(gpg2ssh "$keyid")
507         # remove the old host key line
508         remove_line "$AUTHORIZED_KEYS" "$sshKey"
509         # if key OK, add new host line
510         if [ "$ok" -eq '0' ] ; then
511             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
512
513             # set return code to be 0, since a key was found
514             returnCode=0
515         fi
516     done
517
518     return "$returnCode"
519 }
520
521 # update the authorized_keys files from a list of user IDs on command
522 # line
523 update_authorized_keys() {
524     local userID
525     local returnCode
526
527     # default return code is 0, which assumes a key was found for
528     # every user ID.  code will be set to 1 if a key is not found for
529     # at least one user ID
530     returnCode=0
531
532     # set the trap to remove any lockfiles on exit
533     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
534
535     # create a lockfile on authorized_keys
536     lockfile-create "$AUTHORIZED_KEYS"
537
538     for userID ; do
539         # process the user ID, change return code if key not found for
540         # user ID
541         process_uid_authorized_keys "$userID" || returnCode=1
542
543         # touch the lockfile, for good measure.
544         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
545     done
546
547     # remove the lockfile
548     lockfile-remove "$AUTHORIZED_KEYS"
549
550     return "$returnCode"
551 }
552
553 # process an authorized_user_ids file for authorized_keys
554 process_authorized_user_ids() {
555     local userid
556     local returnCode
557
558     # default return code is 0, and is set to 1 if a key for a user ID
559     # is not found
560     returnCode=0
561
562     authorizedUserIDs="$1"
563
564     # set the IFS to be newline for parsing the authorized_user_ids
565     # file.  can't find it in BASH(1) (found it on the net), but it
566     # works.
567     IFS=$'\n'
568     for userid in $(cat "$authorizedUserIDs" | meat) ; do
569         update_authorized_keys "$userid" || returnCode=1
570     done
571
572     return "$returnCode"
573 }
574
575 # EXPERIMENTAL (unused) process userids found in authorized_keys file
576 # go through line-by-line, extract monkeysphere userids from comment
577 # fields, and process each userid
578 # NOT WORKING
579 process_authorized_keys() {
580     local authorizedKeys
581     local userID
582     local returnCode
583
584     # default return code is 0, and is set to 1 if a key for a user
585     # is not found
586     returnCode=0
587
588     authorizedKeys="$1"
589
590     # take all the monkeysphere userids from the authorized_keys file
591     # comment field (third field) that starts with "MonkeySphere uid:"
592     # FIXME: needs to handle authorized_keys options (field 0)
593     cat "$authorizedKeys" | meat | \
594     while read -r options keytype key comment ; do
595         # if the comment field is empty, assume the third field was
596         # the comment
597         if [ -z "$comment" ] ; then
598             comment="$key"
599         fi
600
601         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
602             continue
603         fi
604         userID=$(echo "$comment" | awk "{ print $2 }")
605         if [ -z "$userID" ] ; then
606             continue
607         fi
608
609         # process the userid
610         log "processing userid: '$userID'"
611         process_user_id "$userID" > /dev/null || returnCode=1
612     done
613
614     return "$returnCode"
615 }
616
617 ##################################################
618 ### GPG HELPER FUNCTIONS
619
620 # retrieve key from web of trust, and set owner trust to "full"
621 # if key is found.
622 trust_key() {
623     local keyID
624     local trustLevel
625
626     keyID="$1"
627     trustLevel="$2"
628
629     if [ -z "$keyID" ] ; then
630         failure "You must specify key to trust."
631     fi
632
633     # get the key from the key server
634     if ! gpg --keyserver "$KEYSERVER" --recv-key "$keyID" ; then
635         failure "Could not retrieve key '$keyID'."
636     fi
637
638     # get key fingerprint
639     fingerprint=$(get_key_fingerprint "$keyID")
640
641     echo "key found:"
642     gpg --fingerprint "$fingerprint"
643
644     while [ -z "$trustLevel" ] ; do
645         cat <<EOF
646 Please decide how far you trust this user to correctly verify other users' keys
647 (by looking at passports, checking fingerprints from different sources, etc.)
648
649   1 = I don't know or won't say
650   2 = I do NOT trust
651   3 = I trust marginally
652   4 = I trust fully
653   5 = I trust ultimately
654
655 EOF
656         read -p "Your decision? " trustLevel
657         if echo "$trustLevel" | grep -v "[1-5]" ; then
658             echo "Unknown trust level '$trustLevel'."
659             unset trustLevel
660         elif [ "$trustLevel" = 'q' ] ; then
661             failure "Aborting."
662         fi
663     done
664
665     # attach a "non-exportable" signature to the key
666     # this is required for the key to have any validity at all
667     # the 'y's on stdin indicates "yes, i really want to sign"
668     echo -e 'y\ny' | gpg --quiet --lsign-key --command-fd 0 "$fingerprint"
669
670     # index trustLevel by one to difference between level in ui and level
671     # internally
672     trustLevel=$((trustLevel+1))
673
674     # import new owner trust level for key
675     echo "${fingerprint}:${trustLevel}:" | gpg --import-ownertrust
676     if [ $? = 0 ] ; then
677         log "Owner trust updated."
678     else
679         failure "There was a problem changing owner trust."
680     fi  
681 }
682
683 # publish server key to keyserver
684 publish_server_key() {
685     read -p "really publish key to $KEYSERVER? [y|N]: " OK; OK=${OK:=N}
686     if [ ${OK/y/Y} != 'Y' ] ; then
687         failure "aborting."
688     fi
689
690     # publish host key
691     # FIXME: need to figure out better way to identify host key
692     # dummy command so as not to publish fakes keys during testing
693     # eventually:
694     #gpg --keyserver "$KEYSERVER" --send-keys $(hostname -f)
695     failure "NOT PUBLISHED (to avoid permanent publication errors during monkeysphere development).
696 To publish manually, do: gpg --keyserver $KEYSERVER --send-keys $(hostname -f)"
697 }