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