#!/usr/bin/bash
# Copyright 2017 Intel Corporation
# See COPYING for the terms.

##### HEALTH CHECKS

is_root() {
    if [ $UID -ne 0 ]; then
        1>&2 echo "Must be root to execute the command."
        return 1
    fi
    return 0
}

has_openssl() {
    if ! command -v openssl >/dev/null 2>&1; then
        1>&2 echo "openssl is required, but not found."
        return 1
    fi
    return 0
}

has_p11kit() {
    if ! command -v p11-kit >/dev/null 2>&1; then
        1>&2 echo "p11-kit is required, but not found."
        return 1
    fi
    return 0
}

# takes cert file as a single argument
# returns 0 if root ca, 1 otherwise
is_root_ca() {
    # if issuer and subject match, then it's a self-signed certificate, the only
    # indication of a Root CA we need to take into account (X.509v3 extensions
    # are not)
    t=$(openssl x509 -in $1 -noout -issuer -subject 2>/dev/null      \
            | sed -e 's/^\(issuer\|subject\)=//' | uniq | wc -l)
    test $t -eq 1
}

##### DEFAULT LOCATIONS

# trust store default location
CLR_TRUST_STORE_DFLT=/var/cache/ca-certs
CLR_TRUST_STORE=${CLR_TRUST_STORE:-$CLR_TRUST_STORE_DFLT}

# user-supplied, local source of trust
CLR_LOCAL_TRUST_SRC=${CLR_LOCAL_TRUST_SRC:-/etc/ca-certs}

# Clear Linux-supplied source of trust
CLR_CLEAR_TRUST_SRC=${CLR_CLEAR_TRUST_SRC:-/usr/share/ca-certs}

##### ERROR CODES
# 0 is always success, used literally
EPERM=1         # operation not permitted
EINVAL=22       # invalid argument
EBADST=127      # invalid state / health check does not pass
EERR=255        # general error

# takes single argument: a directory
# the caller should grab the stdout of the call, it will contain found cert info
# in form of:
# <file name>\t<sha-256 fingerprint>
find_certs() {
    local dir
    dir=$1
    if [ -z $dir ]; then return 255; fi
    if [ ! -d $dir ]; then return 255; fi
    find $dir -maxdepth 1 -type f | while read f; do
        finger=$(openssl x509 -in "${f}" -noout -fingerprint -sha1 2>/dev/null)
        if [ $? -ne 0 ]; then
            1>&2 echo "WARNING: file ${f} is not a certificate"
            continue
        fi
        finger=${finger#SHA1 Fingerprint=}
        printf "%s\t%s\n" "${f}" "${finger}"
    done
    return 0
}

find_all_certs() {
    find_certs $CLR_CLEAR_TRUST_SRC/trusted
    find_certs $CLR_LOCAL_TRUST_SRC/trusted
}

print_generate_help() {
    cat <<EOF
Usage: ${BASENAME} generate [-h|--help]

    This command does not take arguments.

    -h | --help         Prints this help message and exits

    The store is generated from the following locations:
        ${CLR_CLEAR_TRUST_SRC}/trusted
        ${CLR_LOCAL_TRUST_SRC}/trusted
        ${CLR_LOCAL_TRUST_SRC}/distrusted
EOF
}

cmd_generate() {
    local ca_certs
    local dup_certs
    local distrust_certs
    local ca_certs_cnt
    local dup_certs_cnt
    local distrust_certs_cnt
    local tmp

    case "$#:$1" in
        0:)
            ;;
        -h|--help)
            print_generate_help
            return 0
            ;;
        *)
            print_generate_help
            return $EINVAL
            ;;
    esac
    # find all the certificates
    ca_certs=$(find_all_certs)

    # handle the duplicates
    dup_certs=$(echo "${ca_certs}" | cut -f 2 | sort | uniq -d)
    if [ ! -z "$dup_certs" ]; then
        dup_certs_cnt=$(echo "$dup_certs" | wc -l)
        echo "$dup_certs_cnt certificate(s) are duplicated. Cleaning up..."
        for h in ${dup_certs}; do
            tmp=$(echo "${ca_certs}" | grep "${h}" | head -1)
            ca_certs=$(echo "${ca_certs}" | grep -v "${h}")
            ca_certs="${ca_certs}
$tmp"
        done
    fi

    # remove the distrusted ones
    distrust_certs=$(find_certs $CLR_LOCAL_TRUST_SRC/distrusted)
    ca_certs_cnt=$(echo "$ca_certs" | wc -l)
    if [ ! -z "$distrust_certs" ]; then
        distrust_certs_cnt=$(echo "$distrust_certs" | wc -l)
        echo "Distrusting $distrust_certs_cnt certificate(s)."
        for h in $(echo "$distrust_certs" | cut -f 2); do
            ca_certs=$(echo "$ca_certs" | grep -v "${h}")
        done
    fi

    # write the store
    umask 022

    CLR_STORE_STAGE=$(mktemp -d)
    chmod 755 $CLR_STORE_STAGE
    mkdir -p $CLR_STORE_STAGE/anchors
    mkdir -p $CLR_STORE_STAGE/compat

    echo "${ca_certs}" | cut -f 1 | xargs cp -t $CLR_STORE_STAGE/anchors

    if ! (cd $CLR_STORE_STAGE/anchors && c_rehash . >/dev/null 2>&1); then
        1>&2 echo "Error rehashing the anchors."
        rm -r $CLR_STORE_STAGE
        return $EERR
    fi

    TMP=$(mktemp -u)
    if [ -e $CLR_TRUST_STORE ]; then
        mv -f $CLR_TRUST_STORE $TMP
    fi
    mv $CLR_STORE_STAGE $CLR_TRUST_STORE
    if [ -e $TMP ]; then
        rm -rf $TMP
    fi

    # generate the compat after the store is deployed: p11-kit is configured to
    # look at the default location
    p11-kit extract                 \
            --filter=ca-anchors     \
            --format=java-cacerts   \
            --purpose=server-auth   \
            $CLR_TRUST_STORE/compat/ca-roots.keystore
    chmod 644 $CLR_TRUST_STORE/compat/ca-roots.keystore

    p11-kit extract                 \
            --filter=ca-anchors     \
            --format=pem-bundle     \
            --purpose=server-auth   \
            $CLR_TRUST_STORE/compat/ca-roots.pem
    chmod 644 $CLR_TRUST_STORE/compat/ca-roots.pem

    echo "Trust store generated at ${CLR_TRUST_STORE}"
    return 0
}

print_add_help() {
    cat <<EOF
Usage: ${BASENAME} add [-h|--help] <filename>...

    -h | --help         Prints this help message and exits

    <filesname>...      List of files containing a PEM-encoded Root CA certificate(s)
EOF
}

cmd_add() {
    local files
    local ret
    local ca_certs
    local out
    local out_content
    local err
    local err_content
    local tmp
    local opt_force
    opt_force=0
    while [ $# -gt 0 ]; do
        case $1 in
            -h|--help)
                print_add_help
                return 0
                ;;
            -f|--force)
                opt_force=1
                shift
                ;;
            *)
                # first empty line will be deleted few lines later
                files="$files
$1"
                shift
                ;;
        esac
    done
    if [ -z "$files" ]; then
        1>&2 echo "Specify certificate(s) to add."
        print_add_help
        return $EINVAL
    fi
    files=$(echo "$files" | sed -e '1d')
    ca_certs=$(find_all_certs)
    distrusted_certs=$(find_certs $CLR_LOCAL_TRUST_SRC/distrusted)
    err=$(mktemp)
    out=$(mktemp)
    tmp=$(mktemp)
    echo "$files" | while read f; do
        if [ ! -f "$f" ]; then
            echo "No such file $f."
            # get the certificate from the list
            # TODO: get the file, assign it to files
            continue
        fi
        finger=$(openssl x509 -in "${f}" -noout -fingerprint -sha1 2>$tmp)
        if [ $? -ne 0 ]; then
            echo "$f is not an X.509 certificate."
            cat $tmp
            continue
        fi
        finger=${finger#SHA1 Fingerprint=}
        is_root_ca "$f"
        if [ $opt_force -ne 1 -a $? -ne 0 ]; then
            cat <<EOF
Certificate $f is not a Root CA. Use --force and proper judgement to enforce.
EOF
            continue
        fi
        echo "$ca_certs" | grep "$finger" >/dev/null 2>&1
        if [ $? -eq 0 ]; then
            # if it's among trusted certs, then it may be distrusted
            dcert=$(echo "$distrusted_certs" | grep $finger)
            if [ $? -eq 0 ]; then
                1>&2 echo -e "$f\t$finger"
                continue
            else
                cat <<EOF
Certificate $f is already trusted. Not adding duplicates.
EOF
                continue
            fi
        fi
        fname=$(basename $f)
        if [ -f $CLR_LOCAL_TRUST_SRC/trusted/$fname ]; then
            # FIXME: it's not a duplicate, should really not be picky about file
            # names.
            echo "File $fname already exists."
            continue
        fi
    done >$out 2>$err
    out_content=$(cat $out)
    if [ -z "$out_content" ]; then
        err_content=$(cat $err)
        echo "$files" | while read f; do
            l=$(echo "$err_content" | grep "${f}")
            if [ $? -ne 0 ]; then
                cp "${f}" $CLR_LOCAL_TRUST_SRC/trusted
            else
                # lookup distrusted file in the source
                l=$(echo "$l" | cut -f 2)
                l=$(echo "$distrusted_certs" | grep "$l" | cut -f 1)
                if [ -z $l ]; then
                    1>&2 echo "Internal error: ${f} must be distrusted, but not found"
                    continue
                fi
                rm "${l}"
            fi
        done
        cmd_generate
    else
        ret=$EERR
        1>&2 echo "$out_content"
    fi
    rm $err $tmp $out
    return $ret
}

print_list_help() {
    cat <<EOF
Usage: ${BASENAME} list [-h|--help]

    -h | --help         Prints this help message and exits

    Prints the list of certificates, each in form of
        id: <id>
            File: <filename>
            Authority: <issuer name>
            Expires: <expiration date>
EOF
}

cmd_list() {
    local certs
    local info
    local indent
    local err
    while [ $# -gt 0 ]; do
        case $1 in
            -h|--help)
                print_list_help
                return 0
                ;;
            *)
                print_list_help
                return $EINVAL
                ;;
        esac
        shift
    done
    # 4 spaces for sed
    indent="\ \ \ \ "
    if [ ! -d ${CLR_TRUST_STORE}/anchors ]; then
        1>&2 echo "${CLR_TRUST_STORE} is not a trust store." \
            " Use ${BASENAME} generate to create the store."
        return $EERR
    fi
    certs=$(find ${CLR_TRUST_STORE}/anchors -maxdepth 1 -type f | LC_COLLATE=C sort)
    if [ -z "${certs}" ]; then
        test ! -z $VERBOSE && echo "Nothing is trusted. No anchors found in ${CLR_TRUST_STORE}."
        return 0
    fi

    echo "$certs" | while read f; do
        info=$(openssl x509 -in "${f}" -noout -fingerprint -sha1 -issuer -enddate)
        if [ $? -ne 0 ]; then
            1>&2 echo "${f} is not an X.509 certificate."
        fi
        info=$(echo "$info" |                                         \
            sed -e "s/^SHA1 Fingerprint=/id: /"                        \
                    -e "2i${indent}File: ${f}"                          \
                    -e "s/^issuer=\s*/${indent}Authority: /"             \
                    -e "s/^notAfter=\s*/${indent}Expires: /")
        echo "$info"
    done

    return 0
}

print_check_help() {
    true
}

cmd_check() {
    if [ -e $CLR_LOCAL_TRUST_SRC/distrusted ]; then
        if [ ! -d $CLR_LOCAL_TRUST_SRC/distrusted ]; then
            1>&2 echo "$CLR_LOCAL_TRUST_SRC/distrusted must be a directory"
            return $EBADST
        fi
    fi

    return 0
}

print_remove_help() {
    cat <<EOF
Usage: ${BASENAME} remove [-f|--force] <filename|id>...

    Distrusts specified Certificate Authorities. Each CA can be represented
    either by a file containing PEM-encoded X.509 certificate or an id as
    obtained from the list command.

    -f | --force        Forces removal of certificates
    -h | --help         Prints this help message and exits

    <filename|id>...    List of files and/or ids to remove from the store
EOF
}

cmd_remove() {
    local files
    local ids
    local err
    local out
    local invld_files
    local certs
    local opt_force
    while [ $# -gt 0 ]; do
        case $1 in
            -h|--help)
                print_remove_help
                return 0
                ;;
            -f|--force)
                # TODO: implement forcing
                opt_force=1
                true
                ;;
            *)
                if [ -f "$1" ]; then
                    # need newline as a separator in case filename comes with
                    # spaces. first empty line will be deleted later.
                    files="$files
$1"
                else
                    ids="$ids $1"
                fi
                ;;
        esac
        shift
    done

    cmd_check()
    if [ $? -eq $EBADST ]; then
        return $EBADST
    fi

    err=$(mktemp)
    out=$(mktemp)
    files=$(echo "$files" | sed -e '1d')
    test ! -z "$files" && echo "$files" | while read f; do
        finger=$(openssl x509 -in "${f}" -noout -fingerprint -sha1 2>/dev/null)
        if [ $? -ne 0 ]; then
            1>&2 echo "${f} is not an X.509 certificate." >>$err
            continue
        fi
        finger=${finger#SHA1 Fingerprint=}
        printf "%s\t%s\n" "${f}" "${finger}"
    done >$out
    invld_files=$(cat $err)
    if [ ! -z "$invld_files" ]; then
        2>&1 echo "$invld_files"
        2>&1 echo "Trust store has not been modified."
        rm $out $err
        return $EERR
    fi
    files=$(cat $out)
    ids=$(echo $ids && (echo "$files" | cut -f 2))
    certs=$(find_all_certs)
    for id in $ids; do
        f=$(echo "$certs" | grep $id)
        if [ $? -eq 0 ]; then
            echo "$f" | cut -f 1
        else
            f=$(echo "$files" | grep $id | cut -f 1)
            if [ -z $f ]; then
                1>&2 echo "Certificate id $id not found."
            else
                1>&2 echo "Certificate id $id not found (file: $f)."
            fi
        fi
    done >$out
    files=$(cat $out)
    if [ ! -z "$files" ]; then
        echo "$files" | while read f; do
            if [ ! -e $CLR_LOCAL_TRUST_SRC/distrusted ]; then
                mkdir $CLR_LOCAL_TRUST_SRC/distrusted
            fi
            # if certificate is provided by clear trust store, distrust it,
            # otherwise remove it
            if [ $(dirname $f) = $CLR_CLEAR_TRUST_SRC/trusted ]; then
                cp $f $CLR_LOCAL_TRUST_SRC/distrusted
            else
                rm $f
            fi
        done
        cmd_generate
    else
        echo "Nothing to do."
    fi
    rm $err $out
    return 0
}

print_help() {
    cat <<EOF
Usage: ${BASENAME} [-v|--verbose] [-h|--help] <command> [options]

    Commands
        generate    generates the trust store
        list        list CAs
        add         add trust to a CA
        remove      remove trust to a CA
        restore     restore trust to previously removed CA
        check       sanity/consistency check of the trust store

${BASENAME} <command> --help to get help on specific command.
EOF
}

##### GLOBAL VARS/OPTIONS
VERBOSE=
COMMAND=""
BASENAME=$(basename $0) # this may not work, but we don't care too much
ARGS=$*

if ! has_openssl; then
    exit $EBADST
fi

if ! has_p11kit; then
    exit $EBADST
fi

while [ $# -gt 0 ]; do
    opt=$1
    case $opt in
        -v|--verbose)
            VERBOSE=1
            ;;
        -h|--help)
            print_help
            exit 0
            ;;
        generate|list|add|remove|restore|check)
            COMMAND=$opt
            ;;
    esac
    shift
    if [ ! -z $COMMAND ]; then
        break
    fi
done

case $COMMAND in
    generate|add|remove|restore)
        # must be root if writing to the default location
        if [ "x$CLR_TRUST_STORE" = "x$CLR_TRUST_STORE_DFLT" ]; then
            is_root
            if [ $? -ne 0 ]; then
                exit $EPERM
            fi
        fi
        ;;
esac

case $COMMAND in
    generate)
        cmd_generate $*
        exit $?
        ;;
    add)
        cmd_add $*
        exit $?
        ;;
    list)
        cmd_list $*
        exit $?
        ;;
    remove)
        cmd_remove $*
        exit $?
        ;;
    restore|check)
        1>&2 echo "$COMMAND not yet supported."
        exit $EINVAL
        ;;
    *)
        if [ ! -z $COMMAND ]; then
            1>&2 echo "Command not understood: $COMMAND"
        fi
        print_help
        exit $EINVAL
        ;;
esac

# vim: si:noai:nocin:tw=80:sw=4:ts=4:et:nu
