more work on rhesus
[monkeysphere.git] / rhesus / rhesus
index 0c7e1003db01326e0c0a46282aa068c731ea9bb6..f607f0b9c750ff05deb4867ead5ffee8ce09b013 100755 (executable)
@@ -1,25 +1,33 @@
 #!/bin/sh
 
-# rhesus: monkeysphere authorized_keys update script
+# rhesus: monkeysphere authorized_keys/known_hosts generating script
 #
 # Written by
 # Jameson Rollins <jrollins@fifthhorseman.net>
 #
 # Copyright 2008, released under the GPL, version 3 or later
 
-##################################################
-# load conf file
-CONF_FILE=${CONF_FILE:-"/etc/monkeysphere/monkeysphere.conf"}
-. "$CONF_FILE"
+# all caps variables are meant to be user supplied (ie. from config
+# file) and are considered global
 
-export GNUPGHOME
-##################################################
+PGRM=$(basename $0)
+
+# date in UTF format if needed
+DATE=$(date -u '+%FT%T')
+
+# unset some environment variables that could screw things up
+GREP_OPTIONS=
 
-CMD=$(basename $0)
+########################################################################
+# FUNCTIONS
+########################################################################
 
 usage() {
 cat <<EOF
-usage: $CMD USERNAME
+usage: $PGRM k|known_hosts [host...]
+       $PGRM a|authorized_keys [userid...]
+Monkeysphere update of known_hosts or authorized_keys file.
+If hosts/userids are specified, only those specified will be processed
 EOF
 }
 
@@ -28,124 +36,431 @@ failure() {
     exit ${2:-'1'}
 }
 
+# write output to stdout
+log() {
+    echo -n "ms: "
+    echo "$@"
+}
+
+# write output to stderr
+loge() {
+    echo -n "ms: " 1>&2
+    echo "$@" 1>&2
+}
+
+# cut out all comments(#) and blank lines from standard input
 meat() {
-    grep -v -e "^[[:space:]]*#" -e '^$' "$1"
+    grep -v -e "^[[:space:]]*#" -e '^$'
 }
 
+# cut a specified line from standard input
 cutline() {
     head --line="$1" | tail -1
 }
 
-### MAIN
+# retrieve all keys with given user id from keyserver
+# FIXME: need to figure out how to retrieve all matching keys
+# (not just first 5)
+gpg_fetch_keys() {
+    local id
+    id="$1"
+    echo 1,2,3,4,5 | \
+       gpg --quiet --batch --command-fd 0 --with-colons \
+       --keyserver "$KEYSERVER" \
+       --search ="$id" >/dev/null 2>&1
+}
+
+# check that characters are in a string (in an AND fashion).
+# used for checking key capability
+# check_capability capability a [b...]
+check_capability() {
+    local capability
+    local capcheck
+
+    capability="$1"
+    shift 1
+
+    for capcheck ; do
+       if echo "$capability" | grep -q -v "$capcheck" ; then
+           return 1
+       fi
+    done
+    return 0
+}
+
+# convert escaped characters from gpg output back into original
+# character
+# FIXME: undo all escape character translation in with-colons gpg output
+unescape() {
+    echo "$1" | sed 's/\\x3a/:/'
+}
+
+# stand in until we get dkg's gpg2ssh program
+gpg2ssh_tmp() {
+    local mode
+    local keyID
+    local userID
+    local host
+
+    mode="$1"
+    keyID="$2"
+    userID="$3"
+
+    if [ "$mode" = 'authorized_keys' ] ; then
+       gpgkey2ssh "$keyID" | sed -e "s/COMMENT/${userID}/"
+
+    # NOTE: it seems that ssh-keygen -R removes all comment fields from
+    # all lines in the known_hosts file.  why?
+    # NOTE: just in case, the COMMENT can be matched with the
+    # following regexp:
+    # '^MonkeySphere[[:digit:]]{4}(-[[:digit:]]{2}){2}T[[:digit:]]{2}(:[[:digit:]]{2}){2}$'
+    elif [ "$mode" = 'known_hosts' ] ; then
+       host=$(echo "$userID" | sed -e "s|ssh://||")
+       echo -n "$host "; gpgkey2ssh "$keyID" | sed -e "s/COMMENT/MonkeySphere${DATE}/"
+    fi
+}
+
+# userid and key policy checking
+# the following checks policy on the returned keys
+# - checks that full key has appropriate valididy (u|f)
+# - checks key has specified capability (REQUIRED_KEY_CAPABILITY)
+# - checks that particular desired user id has appropriate validity
+# see /usr/share/doc/gnupg/DETAILS.gz
+# expects global variable: "mode"
+process_user_id() {
+    local userID
+    local cacheDir
+    local requiredPubCapability
+    local gpgOut
+    local line
+    local type
+    local validity
+    local keyid
+    local uidfpr
+    local capability
+    local keyOK
+    local pubKeyID
+    local uidOK
+    local keyIDs
+    local userIDHash
+    local keyID
+
+    userID="$1"
+    cacheDir="$2"
+
+    requiredPubCapability=$(echo "$REQUIRED_KEY_CAPABILITY" | tr "[:lower:]" "[:upper:]")
+
+    # fetch keys from keyserver, return 1 if none found
+    gpg_fetch_keys "$userID" || return 1
+
+    # output gpg info for (exact) userid and store
+    gpgOut=$(gpg --fixed-list-mode --list-key --with-colons \
+       ="$userID" 2> /dev/null)
+
+    # return 1 if there only "tru" lines are output from gpg
+    if [ -z "$(echo "$gpgOut" | grep -v '^tru:')" ] ; then
+       return 1
+    fi
+
+    # loop over all lines in the gpg output and process.
+    # need to do it this way (as opposed to "while read...") so that
+    # variables set in loop will be visible outside of loop
+    for line in $(seq 1 $(echo "$gpgOut" | wc -l)) ; do
+
+       # read the contents of the line
+       type=$(echo "$gpgOut" | cutline "$line" | cut -d: -f1)
+       validity=$(echo "$gpgOut" | cutline "$line" | cut -d: -f2)
+       keyid=$(echo "$gpgOut" | cutline "$line" | cut -d: -f5)
+       uidfpr=$(echo "$gpgOut" | cutline "$line" | cut -d: -f10)
+       capability=$(echo "$gpgOut" | cutline "$line" | cut -d: -f12)
+
+       # process based on record type
+       case $type in
+           'pub') # primary keys
+               # new key, wipe the slate
+               keyOK=
+               pubKeyID=
+               uidOK=
+               keyIDs=
+
+               pubKeyID="$keyid"
+
+               # check primary key validity
+               if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
+                   loge "  unacceptable primary key validity ($validity)."
+                   continue
+               fi
+               # check capability is not Disabled...
+               if check_capability "$capability" 'D' ; then
+                   loge "  key disabled."
+                   continue
+               fi
+               # check overall key capability
+               # must be Encryption and Authentication
+               if ! check_capability "$capability" $requiredPubCapability ; then
+                   loge "  unacceptable primary key capability ($capability)."
+                   continue
+               fi
+
+               # mark if primary key is acceptable
+               keyOK=true
+
+               # add primary key ID to key list if it has required capability
+               if check_capability "$capability" $REQUIRED_KEY_CAPABILITY ; then
+                   keyIDs[${#keyIDs[*]}]="$keyid"
+               fi
+               ;;
+           'uid') # user ids
+               # check key ok and we have key fingerprint
+               if [ -z "$keyOK" ] ; then
+                   continue
+               fi
+               # check key validity
+               if [ "$validity" != 'u' -a "$validity" != 'f' ] ; then
+                   continue
+               fi
+               # check the uid matches
+               if [ "$(unescape "$uidfpr")" != "$userID" ] ; then
+                   continue
+               fi
+
+               # mark if uid acceptable
+               uidOK=true
+               ;;
+           'sub') # sub keys
+               # add sub key ID to key list if it has required capability
+               if check_capability "$capability" $REQUIRED_KEY_CAPABILITY ; then
+                   keyIDs[${#keyIDs[*]}]="$keyid"
+               fi
+               ;;
+       esac
+    done
+
+    # hash userid for cache file name
+    userIDHash=$(echo "$userID" | sha1sum | awk '{ print $1 }')
+
+    # touch/clear key cache file
+    # (will be left empty if there are noacceptable keys)
+    > "$cacheDir"/"$userIDHash"."$pubKeyID"
+
+    # for each acceptable key, write an ssh key line to the
+    # key cache file
+    if [ "$keyOK" -a "$uidOK" -a "${keyIDs[*]}" ] ; then
+       for keyID in ${keyIDs[@]} ; do
+           # export the key with gpg2ssh
+            # FIXME: needs to apply extra options for authorized_keys
+           # lines if specified
+           gpg2ssh_tmp "$mode" "$keyID" "$userID" >> "$cacheDir"/"$userIDHash"."$pubKeyID"
+
+           # hash the cache file if specified
+           if [ "$mode" = 'known_hosts' -a "$HASH_KNOWN_HOSTS" ] ; then
+               ssh-keygen -H -f "$cacheDir"/"$userIDHash"."$pubKeyID" > /dev/null 2>&1
+               rm "$cacheDir"/"$userIDHash"."$pubKeyID".old
+           fi
+       done
+    fi
+
+    # echo the path to the key cache file
+    echo "$cacheDir"/"$userIDHash"."$pubKeyID"
+}
+
+# process a host for addition to a known_host file
+process_host() {
+    local host
+    local cacheDir
+    local hostKeyCachePath
+
+    host="$1"
+    cacheDir="$2"
+
+    log "processing host: '$host'"
+
+    hostKeyCachePath=$(process_user_id "ssh://${host}" "$cacheDir")
+    if [ $? = 0 ] ; then
+       ssh-keygen -R "$host" -f "$USER_KNOWN_HOSTS"
+       cat "$hostKeyCachePath" >> "$USER_KNOWN_HOSTS"
+    fi
+}
+
+# process known_hosts file
+# go through line-by-line, extract each host, and process with the
+# host processing function
+process_known_hosts() {
+    local cacheDir
+    local userID
+
+    cacheDir="$1"
+
+    # take all the hosts from the known_hosts file (first field),
+    # grep out all the hashed hosts (lines starting with '|')
+    cut -d ' ' -f 1 "$USER_KNOWN_HOSTS" | \
+    grep -v '^|.*$' | \
+    while IFS=, read -r -a hosts ; do
+       # process each host
+       for host in ${hosts[*]} ; do
+           process_host "$host" "$cacheDir"
+       done
+    done
+}
+
+# process an authorized_*_ids file
+# go through line-by-line, extract each userid, and process
+process_authorized_ids() {
+    local authorizedIDsFile
+    local cacheDir
+    local userID
+    local userKeyCachePath
+
+    authorizedIDsFile="$1"
+    cacheDir="$2"
+
+    # clean out keys file and remake keys directory
+    rm -rf "$cacheDir"
+    mkdir -p "$cacheDir"
+
+    # loop through all user ids in file
+    # FIXME: needs to handle extra options if necessary
+    cat "$authorizedIDsFile" | meat | \
+    while read -r userID ; do
+       # process the userid
+       log "processing userid: '$userID'"
+       userKeyCachePath=$(process_user_id "$userID" "$cacheDir")
+       if [ -s "$userKeyCachePath" ] ; then
+           loge "  acceptable key/uid found."
+       fi
+    done
+}
+
+########################################################################
+# MAIN
+########################################################################
 
 if [ -z "$1" ] ; then
     usage
     exit 1
 fi
 
-# user name of user to update
-USERNAME="$1"
-if ! id "$USERNAME" > /dev/null ; then
-    failure "User '$USERNAME' does not exist."
-fi
+# mode given in first variable
+mode="$1"
+shift 1
 
-AUTH_USER_IDS="$AUTH_USER_IDS_DIR"/"$USERNAME"
-if [ ! -e "$AUTH_USER_IDS" ] ; then
-    failure "No auth_user_ids file for user '$USERNAME'."
+# check user
+if ! id -u "$USER" > /dev/null 2>&1 ; then
+    failure "invalid user '$USER'."
 fi
 
-KEYDIR="$AUTH_KEYS_DIR"/"$USERNAME"/keys
-AUTH_KEYS="$AUTH_KEYS_DIR"/authorized_keys
-
-# make sure the gnupg home exists with proper permissions
-mkdir -p "$GNUPGHOME"
-chmod 0700 "$GNUPGHOME"
-
-# find number of user ids in auth_user_ids file
-NLINES=$(meat "$AUTH_USER_IDS" | wc -l)
-
-# clean out keys file and remake keys directory
-rm -rf "$KEYDIR"
-mkdir -p "$KEYDIR"
-
-# loop through all user ids, and generate ssh keys
-for (( N=1; N<=$NLINES; N=N+1 )) ; do
-    # get user id
-    USERID=$(meat "$AUTH_USER_IDS" | cutline "$N" )
-    USERID_HASH=$(echo "$USERID" | sha1sum | awk '{ print $1 }')
-
-    KEYFILE="$KEYDIR"/"$USERID_HASH"
-
-    # search for key on keyserver
-    echo "ms: validating: '$USERID'"
-    RETURN=$(echo 1 | gpg --quiet --batch --command-fd 0 --with-colons --keyserver "$KEYSERVER" --search ="$USERID")
-
-    # if the key was found...
-    if [ "$RETURN" ] ; then
-       echo "ms:   key found."
-       
-       # checking key attributes
-       # see /usr/share/doc/gnupg/DETAILS.gz:
-       
-       PUB_INFO=$(gpg --fixed-list-mode --with-colons --list-keys --with-fingerprint ="$USERID" | grep '^pub:')
-
-       # extract needed fields
-       KEY_TRUST=$(echo "$PUB_INFO" | cut -d: -f2)
-       KEY_CAPABILITY=$(echo "$PUB_INFO" | cut -d: -f12)
-       
-       # check if key disabled
-       if  echo "$KEY_CAPABILITY" | grep -q '[D]' ; then
-           echo "ms:   key disabled -> SKIPPING"
-           continue
-       fi
+# set user home directory
+HOME=$(getent passwd "$USER" | cut -d: -f6)
+
+# set ms home directory
+MS_HOME=${MS_HOME:-"$HOME"/.config/monkeysphere}
+
+# load configuration file
+MS_CONF=${MS_CONF:-"$MS_HOME"/monkeysphere.conf}
+[ -e "$MS_CONF" ] && . "$MS_CONF"
+
+# set config variable defaults
+STAGING_AREA=${STAGING_AREA:-"$MS_HOME"}
+AUTHORIZED_USER_IDS=${AUTHORIZED_USER_IDS:-"$MS_HOME"/authorized_user_ids}
+GNUPGHOME=${GNUPGHOME:-"$HOME"/.gnupg}
+KEYSERVER=${KEYSERVER:-subkeys.pgp.net}
+REQUIRED_KEY_CAPABILITY=${REQUIRED_KEY_CAPABILITY:-"e a"}
+USER_CONTROLLED_AUTHORIZED_KEYS=${USER_CONTROLLED_AUTHORIZED_KEYS:-"$HOME"/.ssh/authorized_keys}
+USER_KNOWN_HOSTS=${USER_KNOWN_HOSTS:-"$HOME"/.ssh/known_hosts}
+HASH_KNOWN_HOSTS=${HASH_KNOWN_HOSTS:-}
+
+# export USER and GNUPGHOME variables, since they are used by gpg
+export USER
+export GNUPGHOME
 
-        # check key capability
-       REQUIRED_KEY_CAPABILITY=${REQUIRED_KEY_CAPABILITY:-'a'}
-       if  echo "$KEY_CAPABILITY" | grep -q '[$REQUIRED_KEY_CAPABILITY]' ; then
-           echo "ms:   key capability verified ('$KEY_CAPABILITY')."
-       else
-           echo "ms:   unacceptable key capability ('$KEY_CAPABILITY') -> SKIPPING"
-           continue
+# stagging locations
+hostKeysCacheDir="$STAGING_AREA"/host_keys
+userKeysCacheDir="$STAGING_AREA"/user_keys
+msKnownHosts="$STAGING_AREA"/known_hosts
+msAuthorizedKeys="$STAGING_AREA"/authorized_keys
+
+# make sure gpg home exists with proper permissions
+mkdir -p -m 0700 "$GNUPGHOME"
+
+## KNOWN_HOST MODE
+if [ "$mode" = 'known_hosts' -o "$mode" = 'k' ] ; then
+    mode='known_hosts'
+
+    cacheDir="$hostKeysCacheDir"
+
+    log "user '$USER': monkeysphere known_hosts processing"
+
+    # touch the known_hosts file to make sure it exists
+    touch "$USER_KNOWN_HOSTS"
+
+    # if hosts are specified on the command line, process just
+    # those hosts
+    if [ "$1" ] ; then
+        for host ; do
+           process_host "$host" "$cacheDir"
+       done
+
+    # otherwise, if no hosts are specified, process the user
+    # known_hosts file
+    else
+       if [ ! -s "$USER_KNOWN_HOSTS" ] ; then
+           failure "known_hosts file '$USER_KNOWN_HOSTS' is empty."
        fi
+       process_known_hosts "$cacheDir"
+    fi
+
+## AUTHORIZED_KEYS MODE
+elif [ "$mode" = 'authorized_keys' -o "$mode" = 'a' ] ; then
+    mode='authorized_keys'
+
+    cacheDir="$userKeysCacheDir"
 
-       echo -n "ms:   key "
-
-       # if key is not fully trusted exit
-        # (this includes not revoked or expired)
-       # determine trust
-       case "$KEY_TRUST" in
-           'i')
-               echo -n "invalid" ;;
-           'r')
-               echo -n "revoked" ;;
-           'e')
-               echo -n "expired" ;;
-           '-'|'q'|'n'|'m')
-               echo -n "has unacceptable trust" ;;
-           'f'|'u')
-               echo -n "fully trusted"
-                # convert pgp key to ssh key, and write to cache file
-               echo -n " -> generating ssh key..."
-               #gpg2ssh "$KEYID" | sed -e "s/COMMENT/$USERID/" > "$KEYFILE"
-               echo " done."
+    # check auth ids file
+    if [ ! -s "$AUTHORIZED_USER_IDS" ] ; then
+       log "authorized_user_ids file is empty or does not exist."
+       exit
+    fi
+
+    log "user '$USER': monkeysphere authorized_keys processing"
+
+    # if userids are specified on the command line, process just
+    # those userids
+    if [ "$1" ] ; then
+       for userID ; do
+           if ! grep -q "$userID" "$AUTHORIZED_USER_IDS" ; then
+               log "userid '$userID' not in authorized_user_ids file."
                continue
-           ;;
-           *)
-               echo -n "has unknown trust" ;;
-       esac
-       echo ". -> SKIPPING"
+           fi
+           log "processing user id: '$userID'"
+           process_user_id "$userID" "$cacheDir" > /dev/null
+       done
+
+    # otherwise, if no userids are specified, process the entire
+    # authorized_user_ids file
     else
-       echo "ms:   key not found."
+       process_authorized_ids "$AUTHORIZED_USER_IDS" "$cacheDir"
     fi
-done
 
-if [ $(ls "$KEYDIR") ]  ; then
-    echo "ms: writing ms authorized_keys file..."
-    cat "$KEYDIR"/* > "$AUTH_KEYS"
+    # write output key file
+    log "writing monkeysphere authorized_keys file... "
+    touch "$msAuthorizedKeys"
+    if [ "$(ls "$cacheDir")" ] ; then
+       log -n "adding gpg keys... "
+       cat "$cacheDir"/* > "$msAuthorizedKeys"
+       echo "done."
+    else
+       log "no gpg keys to add."
+    fi
+    if [ "$USER_CONTROLLED_AUTHORIZED_KEYS" ] ; then
+       if [ -s "$USER_CONTROLLED_AUTHORIZED_KEYS" ] ; then
+           log -n "adding user authorized_keys file... "
+           cat "$USER_CONTROLLED_AUTHORIZED_KEYS" >> "$msAuthorizedKeys"
+           echo "done."
+       fi
+    fi
+    log "monkeysphere authorized_keys file generated:"
+    log "$msAuthorizedKeys"
+
 else
-    echo "ms: no gpg keys to add to authorized_keys file."
-fi
-if [ -s ~"$USERNAME"/.ssh/authorized_keys ] ; then
-    echo "ms: adding user authorized_keys..."
-    cat ~"$USERNAME"/.ssh/authorized_keys >> "$AUTH_KEYS"
+    failure "unknown command '$mode'."
 fi