5d43fa4aa15a44a054037b46c35edf405c122e53
[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 string is in the file and removed, return 0
87     if grep -q -F "$string" "$file" 2> /dev/null ; then
88         grep -v -F "$string" "$file" | sponge "$file"
89         return 0
90
91     # otherwise return 1
92     else
93         return 1
94     fi
95 }
96
97 # translate ssh-style path variables %h and %u
98 translate_ssh_variables() {
99     local uname
100     local home
101
102     uname="$1"
103     path="$2"
104
105     # get the user's home directory
106     userHome=$(getent passwd "$uname" | cut -d: -f6)
107
108     # translate '%u' to user name
109     path=${path/\%u/"$uname"}
110     # translate '%h' to user home directory
111     path=${path/\%h/"$userHome"}
112
113     echo "$path"
114 }
115
116 # test that a string to conforms to GPG's expiration format
117 test_gpg_expire() {
118     echo "$1" | egrep -q "^[0-9][mwy]?$"
119 }
120
121 ### CONVERSION UTILITIES
122
123 # output the ssh key for a given key ID
124 gpg2ssh() {
125     local keyID
126     
127     keyID="$1"
128
129     gpg --export "$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     local returnCode
198
199     if [ "$CHECK_KEYSERVER" != 'true' ] ; then
200         return 0
201     fi
202
203     userID="$1"
204
205     log -n " checking keyserver $KEYSERVER... "
206     echo 1,2,3,4,5 | \
207         gpg --quiet --batch --with-colons \
208         --command-fd 0 --keyserver "$KEYSERVER" \
209         --search ="$userID" > /dev/null 2>&1
210     returnCode="$?"
211     loge "done."
212
213     # if the user is the monkeysphere user, then update the
214     # monkeysphere user's trustdb
215     if [ $(id -un) = "$MONKEYSPHERE_USER" ] ; then
216         gpg_authentication "--check-trustdb" > /dev/null 2>&1
217     fi
218
219     return "$returnCode"
220 }
221
222 ########################################################################
223 ### PROCESSING FUNCTIONS
224
225 # userid and key policy checking
226 # the following checks policy on the returned keys
227 # - checks that full key has appropriate valididy (u|f)
228 # - checks key has specified capability (REQUIRED_*_KEY_CAPABILITY)
229 # - checks that requested user ID has appropriate validity
230 # (see /usr/share/doc/gnupg/DETAILS.gz)
231 # output is one line for every found key, in the following format:
232 #
233 # flag fingerprint
234 #
235 # "flag" is an acceptability flag, 0 = ok, 1 = bad
236 # "fingerprint" is the fingerprint of the key
237 #
238 # expects global variable: "MODE"
239 process_user_id() {
240     local userID
241     local requiredCapability
242     local requiredPubCapability
243     local gpgOut
244     local type
245     local validity
246     local keyid
247     local uidfpr
248     local usage
249     local keyOK
250     local uidOK
251     local lastKey
252     local lastKeyOK
253     local fingerprint
254
255     userID="$1"
256
257     # set the required key capability based on the mode
258     if [ "$MODE" = 'known_hosts' ] ; then
259         requiredCapability="$REQUIRED_HOST_KEY_CAPABILITY"
260     elif [ "$MODE" = 'authorized_keys' ] ; then
261         requiredCapability="$REQUIRED_USER_KEY_CAPABILITY"      
262     fi
263     requiredPubCapability=$(echo "$requiredCapability" | tr "[:lower:]" "[:upper:]")
264
265     # fetch the user ID if necessary/requested
266     gpg_fetch_userid "$userID"
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 nKeys
390     local nKeysOK
391     local ok
392     local keyid
393     local tmpfile
394
395     host="$1"
396
397     log "processing host: $host"
398
399     userID="ssh://${host}"
400
401     nKeys=0
402     nKeysOK=0
403
404     for line in $(process_user_id "ssh://${host}") ; do
405         # note that key was found
406         nKeys=$((nKeys+1))
407
408         ok=$(echo "$line" | cut -d: -f1)
409         keyid=$(echo "$line" | cut -d: -f2)
410
411         sshKey=$(gpg2ssh "$keyid")
412
413         # remove the old host key line, and note if removed
414         remove_line "$KNOWN_HOSTS" "$sshKey"
415
416         # if key OK, add new host line
417         if [ "$ok" -eq '0' ] ; then
418             # note that key was found ok
419             nKeysOK=$((nKeysOK+1))
420
421             # hash if specified
422             if [ "$HASH_KNOWN_HOSTS" = 'true' ] ; then
423                 # FIXME: this is really hackish cause ssh-keygen won't
424                 # hash from stdin to stdout
425                 tmpfile=$(mktemp)
426                 ssh2known_hosts "$host" "$sshKey" > "$tmpfile"
427                 ssh-keygen -H -f "$tmpfile" 2> /dev/null
428                 cat "$tmpfile" >> "$KNOWN_HOSTS"
429                 rm -f "$tmpfile" "${tmpfile}.old"
430             else
431                 ssh2known_hosts "$host" "$sshKey" >> "$KNOWN_HOSTS"
432             fi
433         fi
434     done
435
436     # if at least one key was found...
437     if [ "$nKeys" -gt 0 ] ; then
438         # if ok keys were found, return 0
439         if [ "$nKeysOK" -gt 0 ] ; then
440             return 0
441         # else return 2
442         else
443             return 2
444         fi
445     # if no keys were found, return 1
446     else
447         return 1
448     fi
449 }
450
451 # update the known_hosts file for a set of hosts listed on command
452 # line
453 update_known_hosts() {
454     local nHosts
455     local nHostsOK
456     local nHostsBAD
457     local host
458
459     # the number of hosts specified on command line
460     nHosts="$#"
461
462     nHostsOK=0
463     nHostsBAD=0
464
465     # set the trap to remove any lockfiles on exit
466     trap "lockfile-remove $KNOWN_HOSTS" EXIT
467
468     # create a lockfile on known_hosts
469     lockfile-create "$KNOWN_HOSTS"
470
471     for host ; do
472         # process the host
473         process_host_known_hosts "$host"
474         # note the result
475         case "$?" in
476             0)
477                 nHostsOK=$((nHostsOK+1))
478                 ;;
479             2)
480                 nHostsBAD=$((nHostsBAD+1))
481                 ;;
482         esac
483
484         # touch the lockfile, for good measure.
485         lockfile-touch --oneshot "$KNOWN_HOSTS"
486     done
487
488     # remove the lockfile
489     lockfile-remove "$KNOWN_HOSTS"
490
491     # note if the known_hosts file was updated
492     if [ "$nHostsOK" -gt 0 -o "$nHostsBAD" -gt 0 ] ; then
493         log "known_hosts file updated."
494     fi
495
496     # if an acceptable host was found, return 0
497     if [ "$nHostsOK" -gt 0 ] ; then
498         return 0
499     # else if no ok hosts were found...
500     else
501         # if no bad host were found then no hosts were found at all,
502         # and return 1
503         if [ "$nHostsBAD" -eq 0 ] ; then
504             return 1
505         # else if at least one bad host was found, return 2
506         else
507             return 2
508         fi
509     fi
510 }
511
512 # process hosts from a known_hosts file
513 process_known_hosts() {
514     local hosts
515
516     log "processing known_hosts file..."
517
518     hosts=$(meat "$KNOWN_HOSTS" | cut -d ' ' -f 1 | grep -v '^|.*$' | tr , ' ' | tr '\n' ' ')
519
520     # take all the hosts from the known_hosts file (first
521     # field), grep out all the hashed hosts (lines starting
522     # with '|')...
523     update_known_hosts $hosts
524 }
525
526 # process uids for the authorized_keys file
527 process_uid_authorized_keys() {
528     local userID
529     local nKeys
530     local nKeysOK
531     local ok
532     local keyid
533
534     userID="$1"
535
536     log "processing user ID: $userID"
537
538     nKeys=0
539     nKeysOK=0
540
541     for line in $(process_user_id "$userID") ; do
542         # note that key was found
543         nKeys=$((nKeys+1))
544
545         ok=$(echo "$line" | cut -d: -f1)
546         keyid=$(echo "$line" | cut -d: -f2)
547
548         sshKey=$(gpg2ssh "$keyid")
549
550         # remove the old host key line
551         remove_line "$AUTHORIZED_KEYS" "$sshKey"
552
553         # if key OK, add new host line
554         if [ "$ok" -eq '0' ] ; then
555             # note that key was found ok
556             nKeysOK=$((nKeysOK+1))
557
558             ssh2authorized_keys "$userID" "$sshKey" >> "$AUTHORIZED_KEYS"
559         fi
560     done
561
562     # if at least one key was found...
563     if [ "$nKeys" -gt 0 ] ; then
564         # if ok keys were found, return 0
565         if [ "$nKeysOK" -gt 0 ] ; then
566             return 0
567         # else return 2
568         else
569             return 2
570         fi
571     # if no keys were found, return 1
572     else
573         return 1
574     fi
575 }
576
577 # update the authorized_keys files from a list of user IDs on command
578 # line
579 update_authorized_keys() {
580     local userID
581     local nIDs
582     local nIDsOK
583     local nIDsBAD
584
585     # the number of ids specified on command line
586     nIDs="$#"
587
588     nIDsOK=0
589     nIDsBAD=0
590
591     # set the trap to remove any lockfiles on exit
592     trap "lockfile-remove $AUTHORIZED_KEYS" EXIT
593
594     # create a lockfile on authorized_keys
595     lockfile-create "$AUTHORIZED_KEYS"
596
597     for userID ; do
598         # process the user ID, change return code if key not found for
599         # user ID
600         process_uid_authorized_keys "$userID"
601
602         # note the result
603         case "$?" in
604             0)
605                 nIDsOK=$((nIDsOK+1))
606                 ;;
607             2)
608                 nIDsBAD=$((nIDsBAD+1))
609                 ;;
610         esac
611
612         # touch the lockfile, for good measure.
613         lockfile-touch --oneshot "$AUTHORIZED_KEYS"
614     done
615
616     # remove the lockfile
617     lockfile-remove "$AUTHORIZED_KEYS"
618
619     # note if the authorized_keys file was updated
620     if [ "$nIDsOK" -gt 0 -o "$nIDsBAD" -gt 0 ] ; then
621         log "authorized_keys file updated."
622     fi
623
624     # if an acceptable id was found, return 0
625     if [ "$nIDsOK" -gt 0 ] ; then
626         return 0
627     # else if no ok ids were found...
628     else
629         # if no bad ids were found then no ids were found at all, and
630         # return 1
631         if [ "$nIDsBAD" -eq 0 ] ; then
632             return 1
633         # else if at least one bad id was found, return 2
634         else
635             return 2
636         fi
637     fi
638 }
639
640 # process an authorized_user_ids file for authorized_keys
641 process_authorized_user_ids() {
642     local line
643     local userIDs
644
645     authorizedUserIDs="$1"
646
647     log "processing authorized_user_ids file..."
648
649     # extract user IDs from authorized_user_ids file
650     for line in $(seq 1 $(meat "$authorizedUserIDs" | wc -l)) ; do
651         userIDs[$((line-1))]=$(cutline "$line" "$authorizedUserIDs")
652     done
653
654     update_authorized_keys "${userIDs[@]}"
655 }
656
657 # EXPERIMENTAL (unused) process userids found in authorized_keys file
658 # go through line-by-line, extract monkeysphere userids from comment
659 # fields, and process each userid
660 # NOT WORKING
661 process_authorized_keys() {
662     local authorizedKeys
663     local userID
664     local returnCode
665
666     # default return code is 0, and is set to 1 if a key for a user
667     # is not found
668     returnCode=0
669
670     authorizedKeys="$1"
671
672     # take all the monkeysphere userids from the authorized_keys file
673     # comment field (third field) that starts with "MonkeySphere uid:"
674     # FIXME: needs to handle authorized_keys options (field 0)
675     meat "$authorizedKeys" | \
676     while read -r options keytype key comment ; do
677         # if the comment field is empty, assume the third field was
678         # the comment
679         if [ -z "$comment" ] ; then
680             comment="$key"
681         fi
682
683         if echo "$comment" | egrep -v -q '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}' ; then
684             continue
685         fi
686         userID=$(echo "$comment" | awk "{ print $2 }")
687         if [ -z "$userID" ] ; then
688             continue
689         fi
690
691         # process the userid
692         log "processing userid: '$userID'"
693         process_user_id "$userID" > /dev/null || returnCode=1
694     done
695
696     return "$returnCode"
697 }