New client/server components:
[monkeysphere.git] / src / rhesus / rhesus
1 #!/bin/sh
2
3 # rhesus: monkeysphere authorized_keys/known_hosts generating script
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 PGRM=$(basename $0)
14
15 # date in UTF format if needed
16 DATE=$(date -u '+%FT%T')
17
18 # unset some environment variables that could screw things up
19 GREP_OPTIONS=
20
21 ########################################################################
22 # FUNCTIONS
23 ########################################################################
24
25 usage() {
26 cat <<EOF
27 usage: $PGRM k|known_hosts [host...]
28        $PGRM a|authorized_keys [userid...]
29 Monkeysphere update of known_hosts or authorized_keys file.
30 If hosts/userids are specified, only those specified will be processed
31 EOF
32 }
33
34 failure() {
35     echo "$1" >&2
36     exit ${2:-'1'}
37 }
38
39 # write output to stdout
40 log() {
41     echo -n "ms: "
42     echo "$@"
43 }
44
45 # write output to stderr
46 loge() {
47     echo -n "ms: " 1>&2
48     echo "$@" 1>&2
49 }
50
51 # cut out all comments(#) and blank lines from standard input
52 meat() {
53     grep -v -e "^[[:space:]]*#" -e '^$'
54 }
55
56 # cut a specified line from standard input
57 cutline() {
58     head --line="$1" | tail -1
59 }
60
61 # retrieve all keys with given user id from keyserver
62 # FIXME: need to figure out how to retrieve all matching keys
63 # (not just first 5)
64 gpg_fetch_keys() {
65     local id
66     id="$1"
67     echo 1,2,3,4,5 | \
68         gpg --quiet --batch --command-fd 0 --with-colons \
69         --keyserver "$KEYSERVER" \
70         --search ="$id" >/dev/null 2>&1
71 }
72
73 # check that characters are in a string (in an AND fashion).
74 # used for checking key capability
75 # check_capability capability a [b...]
76 check_capability() {
77     local capability
78     local capcheck
79
80     capability="$1"
81     shift 1
82
83     for capcheck ; do
84         if echo "$capability" | grep -q -v "$capcheck" ; then
85             return 1
86         fi
87     done
88     return 0
89 }
90
91 # convert escaped characters from gpg output back into original
92 # character
93 # FIXME: undo all escape character translation in with-colons gpg output
94 unescape() {
95     echo "$1" | sed 's/\\x3a/:/'
96 }
97
98 # stand in until we get dkg's gpg2ssh program
99 gpg2ssh_tmp() {
100     local mode
101     local keyID
102     local userID
103     local host
104
105     mode="$1"
106     keyID="$2"
107     userID="$3"
108
109     if [ "$mode" = 'authorized_keys' ] ; then
110         gpgkey2ssh "$keyID" | sed -e "s/COMMENT/${userID}/"
111
112     # NOTE: it seems that ssh-keygen -R removes all comment fields from
113     # all lines in the known_hosts file.  why?
114     # NOTE: just in case, the COMMENT can be matched with the
115     # following regexp:
116     # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
117     elif [ "$mode" = 'known_hosts' ] ; then
118         host=$(echo "$userID" | sed -e "s|ssh://||")
119         echo -n "$host "; gpgkey2ssh "$keyID" | sed -e "s/COMMENT/MonkeySphere${DATE}/"
120     fi
121 }
122
123 # userid and key policy checking
124 # the following checks policy on the returned keys
125 # - checks that full key has appropriate valididy (u|f)
126 # - checks key has specified capability (REQUIRED_KEY_CAPABILITY)
127 # - checks that particular desired user id has appropriate validity
128 # see /usr/share/doc/gnupg/DETAILS.gz
129 # expects global variable: "mode"
130 process_user_id() {
131     local userID
132     local cacheDir
133     local requiredPubCapability
134     local gpgOut
135     local line
136     local type
137     local validity
138     local keyid
139     local uidfpr
140     local capability
141     local keyOK
142     local pubKeyID
143     local uidOK
144     local keyIDs
145     local userIDHash
146     local keyID
147
148     userID="$1"
149     cacheDir="$2"
150
151     requiredPubCapability=$(echo "$REQUIRED_KEY_CAPABILITY" | tr "[:lower:]" "[:upper:]")
152
153     # fetch keys from keyserver, return 1 if none found
154     gpg_fetch_keys "$userID" || return 1
155
156     # output gpg info for (exact) userid and store
157     gpgOut=$(gpg --fixed-list-mode --list-key --with-colons \
158         ="$userID" 2> /dev/null)
159
160     # return 1 if there only "tru" lines are output from gpg
161     if [ -z "$(echo "$gpgOut" | grep -v '^tru:')" ] ; then
162         return 1
163     fi
164
165     # loop over all lines in the gpg output and process.
166     # need to do it this way (as opposed to "while read...") so that
167     # variables set in loop will be visible outside of loop
168     for line in $(seq 1 $(echo "$gpgOut" | wc -l)) ; do
169
170         # read the contents of the line
171         type=$(echo "$gpgOut" | cutline "$line" | cut -d: -f1)
172         validity=$(echo "$gpgOut" | cutline "$line" | cut -d: -f2)
173         keyid=$(echo "$gpgOut" | cutline "$line" | cut -d: -f5)
174         uidfpr=$(echo "$gpgOut" | cutline "$line" | cut -d: -f10)
175         capability=$(echo "$gpgOut" | cutline "$line" | cut -d: -f12)
176
177         # process based on record type
178         case $type in
179             'pub') # primary keys
180                 # new key, wipe the slate
181                 keyOK=
182                 pubKeyID=
183                 uidOK=
184                 keyIDs=
185
186                 pubKeyID="$keyid"
187
188                 # check primary key validity
189                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
190                     loge "  unacceptable primary key validity ($validity)."
191                     continue
192                 fi
193                 # check capability is not Disabled...
194                 if check_capability "$capability" 'D' ; then
195                     loge "  key disabled."
196                     continue
197                 fi
198                 # check overall key capability
199                 # must be Encryption and Authentication
200                 if ! check_capability "$capability" $requiredPubCapability ; then
201                     loge "  unacceptable primary key capability ($capability)."
202                     continue
203                 fi
204
205                 # mark if primary key is acceptable
206                 keyOK=true
207
208                 # add primary key ID to key list if it has required capability
209                 if check_capability "$capability" $REQUIRED_KEY_CAPABILITY ; then
210                     keyIDs[${#keyIDs[*]}]="$keyid"
211                 fi
212                 ;;
213             'uid') # user ids
214                 # check key ok and we have key fingerprint
215                 if [ -z "$keyOK" ] ; then
216                     continue
217                 fi
218                 # check key validity
219                 if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
220                     continue
221                 fi
222                 # check the uid matches
223                 if [ "$(unescape "$uidfpr")" != "$userID" ] ; then
224                     continue
225                 fi
226
227                 # mark if uid acceptable
228                 uidOK=true
229                 ;;
230             'sub') # sub keys
231                 # add sub key ID to key list if it has required capability
232                 if check_capability "$capability" $REQUIRED_KEY_CAPABILITY ; then
233                     keyIDs[${#keyIDs[*]}]="$keyid"
234                 fi
235                 ;;
236         esac
237     done
238
239     # hash userid for cache file name
240     userIDHash=$(echo "$userID" | sha1sum | awk '{ print $1 }')
241
242     # touch/clear key cache file
243     # (will be left empty if there are noacceptable keys)
244     > "$cacheDir"/"$userIDHash"."$pubKeyID"
245
246     # for each acceptable key, write an ssh key line to the
247     # key cache file
248     if [ "$keyOK" -a "$uidOK" -a "${keyIDs[*]}" ] ; then
249         for keyID in ${keyIDs[@]} ; do
250             # export the key with gpg2ssh
251             # FIXME: needs to apply extra options for authorized_keys
252             # lines if specified
253             gpg2ssh_tmp "$mode" "$keyID" "$userID" >> "$cacheDir"/"$userIDHash"."$pubKeyID"
254
255             # hash the cache file if specified
256             if [ "$mode" = 'known_hosts' -a "$HASH_KNOWN_HOSTS" ] ; then
257                 ssh-keygen -H -f "$cacheDir"/"$userIDHash"."$pubKeyID" > /dev/null 2>&1
258                 rm "$cacheDir"/"$userIDHash"."$pubKeyID".old
259             fi
260         done
261     fi
262
263     # echo the path to the key cache file
264     echo "$cacheDir"/"$userIDHash"."$pubKeyID"
265 }
266
267 # process a host for addition to a known_host file
268 process_host() {
269     local host
270     local cacheDir
271     local hostKeyCachePath
272
273     host="$1"
274     cacheDir="$2"
275
276     log "processing host: '$host'"
277
278     hostKeyCachePath=$(process_user_id "ssh://${host}" "$cacheDir")
279     if [ $? = 0 ] ; then
280         ssh-keygen -R "$host" -f "$USER_KNOWN_HOSTS"
281         cat "$hostKeyCachePath" >> "$USER_KNOWN_HOSTS"
282     fi
283 }
284
285 # process known_hosts file
286 # go through line-by-line, extract each host, and process with the
287 # host processing function
288 process_known_hosts() {
289     local cacheDir
290     local userID
291
292     cacheDir="$1"
293
294     # take all the hosts from the known_hosts file (first field),
295     # grep out all the hashed hosts (lines starting with '|')
296     cut -d ' ' -f 1 "$USER_KNOWN_HOSTS" | \
297     grep -v '^|.*$' | \
298     while IFS=, read -r -a hosts ; do
299         # process each host
300         for host in ${hosts[*]} ; do
301             process_host "$host" "$cacheDir"
302         done
303     done
304 }
305
306 # process an authorized_*_ids file
307 # go through line-by-line, extract each userid, and process
308 process_authorized_ids() {
309     local authorizedIDsFile
310     local cacheDir
311     local userID
312     local userKeyCachePath
313
314     authorizedIDsFile="$1"
315     cacheDir="$2"
316
317     # clean out keys file and remake keys directory
318     rm -rf "$cacheDir"
319     mkdir -p "$cacheDir"
320
321     # loop through all user ids in file
322     # FIXME: needs to handle extra options if necessary
323     cat "$authorizedIDsFile" | meat | \
324     while read -r userID ; do
325         # process the userid
326         log "processing userid: '$userID'"
327         userKeyCachePath=$(process_user_id "$userID" "$cacheDir")
328         if [ -s "$userKeyCachePath" ] ; then
329             loge "  acceptable key/uid found."
330         fi
331     done
332 }
333
334 ########################################################################
335 # MAIN
336 ########################################################################
337
338 if [ -z "$1" ] ; then
339     usage
340     exit 1
341 fi
342
343 # mode given in first variable
344 mode="$1"
345 shift 1
346
347 # check user
348 if ! id -u "$USER" > /dev/null 2>&1 ; then
349     failure "invalid user '$USER'."
350 fi
351
352 # set user home directory
353 HOME=$(getent passwd "$USER" | cut -d: -f6)
354
355 # set ms home directory
356 MS_HOME=${MS_HOME:-"$HOME"/.config/monkeysphere}
357
358 # load configuration file
359 MS_CONF=${MS_CONF:-"$MS_HOME"/monkeysphere.conf}
360 [ -e "$MS_CONF" ] && . "$MS_CONF"
361
362 # set config variable defaults
363 STAGING_AREA=${STAGING_AREA:-"$MS_HOME"}
364 AUTHORIZED_USER_IDS=${AUTHORIZED_USER_IDS:-"$MS_HOME"/authorized_user_ids}
365 GNUPGHOME=${GNUPGHOME:-"$HOME"/.gnupg}
366 KEYSERVER=${KEYSERVER:-subkeys.pgp.net}
367 REQUIRED_KEY_CAPABILITY=${REQUIRED_KEY_CAPABILITY:-"e a"}
368 USER_CONTROLLED_AUTHORIZED_KEYS=${USER_CONTROLLED_AUTHORIZED_KEYS:-"$HOME"/.ssh/authorized_keys}
369 USER_KNOWN_HOSTS=${USER_KNOWN_HOSTS:-"$HOME"/.ssh/known_hosts}
370 HASH_KNOWN_HOSTS=${HASH_KNOWN_HOSTS:-}
371
372 # export USER and GNUPGHOME variables, since they are used by gpg
373 export USER
374 export GNUPGHOME
375
376 # stagging locations
377 hostKeysCacheDir="$STAGING_AREA"/host_keys
378 userKeysCacheDir="$STAGING_AREA"/user_keys
379 msKnownHosts="$STAGING_AREA"/known_hosts
380 msAuthorizedKeys="$STAGING_AREA"/authorized_keys
381
382 # make sure gpg home exists with proper permissions
383 mkdir -p -m 0700 "$GNUPGHOME"
384
385 ## KNOWN_HOST MODE
386 if [ "$mode" = 'known_hosts' -o "$mode" = 'k' ] ; then
387     mode='known_hosts'
388
389     cacheDir="$hostKeysCacheDir"
390
391     log "user '$USER': monkeysphere known_hosts processing"
392
393     # touch the known_hosts file to make sure it exists
394     touch "$USER_KNOWN_HOSTS"
395
396     # if hosts are specified on the command line, process just
397     # those hosts
398     if [ "$1" ] ; then
399         for host ; do
400             process_host "$host" "$cacheDir"
401         done
402
403     # otherwise, if no hosts are specified, process the user
404     # known_hosts file
405     else
406         if [ ! -s "$USER_KNOWN_HOSTS" ] ; then
407             failure "known_hosts file '$USER_KNOWN_HOSTS' is empty."
408         fi
409         process_known_hosts "$cacheDir"
410     fi
411
412 ## AUTHORIZED_KEYS MODE
413 elif [ "$mode" = 'authorized_keys' -o "$mode" = 'a' ] ; then
414     mode='authorized_keys'
415
416     cacheDir="$userKeysCacheDir"
417
418     # check auth ids file
419     if [ ! -s "$AUTHORIZED_USER_IDS" ] ; then
420         log "authorized_user_ids file is empty or does not exist."
421         exit
422     fi
423
424     log "user '$USER': monkeysphere authorized_keys processing"
425
426     # if userids are specified on the command line, process just
427     # those userids
428     if [ "$1" ] ; then
429         for userID ; do
430             if ! grep -q "$userID" "$AUTHORIZED_USER_IDS" ; then
431                 log "userid '$userID' not in authorized_user_ids file."
432                 continue
433             fi
434             log "processing user id: '$userID'"
435             process_user_id "$userID" "$cacheDir" > /dev/null
436         done
437
438     # otherwise, if no userids are specified, process the entire
439     # authorized_user_ids file
440     else
441         process_authorized_ids "$AUTHORIZED_USER_IDS" "$cacheDir"
442     fi
443
444     # write output key file
445     log "writing monkeysphere authorized_keys file... "
446     touch "$msAuthorizedKeys"
447     if [ "$(ls "$cacheDir")" ] ; then
448         log -n "adding gpg keys... "
449         cat "$cacheDir"/* > "$msAuthorizedKeys"
450         echo "done."
451     else
452         log "no gpg keys to add."
453     fi
454     if [ "$USER_CONTROLLED_AUTHORIZED_KEYS" ] ; then
455         if [ -s "$USER_CONTROLLED_AUTHORIZED_KEYS" ] ; then
456             log -n "adding user authorized_keys file... "
457             cat "$USER_CONTROLLED_AUTHORIZED_KEYS" >> "$msAuthorizedKeys"
458             echo "done."
459         fi
460     fi
461     log "monkeysphere authorized_keys file generated:"
462     log "$msAuthorizedKeys"
463
464 else
465     failure "unknown command '$mode'."
466 fi