#!/bin/bash
#
# gitprotect.sh
#
# Copyright (C) 2013, Branko Majic <branko@majic.rs>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#

program="gitprotect.sh"
version="0.1"

function usage() {
    cat <<EOF
$program $version, a utility for managing GPG-protected directories in a git
repository.

Usage: $program [OPTIONS] command

$program is a utility for managing GPG-protected directories in a git
repository. The main intention of the utility is to provide a viable solution
for storing the passwords centrally, allowing small geographically disperse
teams to exchange them in a secure manner. The utility relies on using GnuPG
utility for performing all tasks related to encryption and decryption.

The script works on induvidual directories by keeping the GnuPG keyring used for
encryption in the subdirectory .gnupg, and using a sub-directory 'decrypted' for
storing unencrypted content.

The following commands are provided for managing the repository/directories:

    init

    Initialises the current directory of a git repository, setting it up for use
    with gitencrypt.

    addkey

    Adds keys to directory's GnuPG public keyring, and marks them as
    trusted. Command expects one or more positional arguments which should be
    either files containing an OpenPGP public key, or key identifiers from
    user's own keyring.

    rmkey

    Removes one or more keys from the git repository GnuPG keyring. Command
    expects one or more positional arguments which should be key identifiers
    from the git repository directory's GnuPG keyring. 

    listkeys

    Lists the keys from the git repository GnuPG keyring.

    encrypt

    Encrypts all the files from the 'decrypted' sub-directory, and stores them
    in the initialised directory. The encrypted files will have the .gpg
    extension.

    decrypt

    Decrypts all the files from the current directory, storing them in the
    'decrypted' sub-directory. Only the files with extension .gpg are decrypted.

$program accepts the following options:

    -v        show script version and licensing information
    -h        show usage help


Please report bugs and send feature requests to <branko@majic.rs>.
EOF
}

function version() {
        cat <<EOF
$program, version $version

+-----------------------------------------------------------------------+
| Copyright (C) 2013, Branko Majic <branko@majic.rs>                    |
|                                                                       |
| This program is free software: you can redistribute it and/or modify  |
| it under the terms of the GNU General Public License as published by  |
| the Free Software Foundation, either version 3 of the License, or     |
| (at your option) any later version.                                   |
|                                                                       |
| This program is distributed in the hope that it will be useful,       |
| but WITHOUT ANY WARRANTY; without even the implied warranty of        |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         |
| GNU General Public License for more details.                          |
|                                                                       |
| You should have received a copy of the GNU General Public License     |
| along with this program.  If not, see <http://www.gnu.org/licenses/>. |
+-----------------------------------------------------------------------+

EOF
}

#
# Checks if the current directory is a sub-directory of a git repository or not.
#
# Returns:
#   0 if the current directory is a sub-directory of a git repository, 1
#   otherwise.
#
# Outputs:
#   If the current directory is not a sub-directory of a git repository, it will
#   output an informative message
#
function inGit() {
    # Check if we are inside of a git repository.
    if ! git rev-parse --git-dir >/dev/null 2>&1; then
        echo "ERROR: $program must be executed from within a git repository." >&2
        echo "Working directory is '$(pwd)'" >&2
        return 1
    fi

    return 0
}

#
# Checks if the current directory has been configured for gitprotect or not.
#
# Returns:
#   0 if the current directory has been configured for use with gitprotect, 1
#   otherwise.
#
# Outputs:
#   If the current directory has not been configured, it will output an
#   informative error message.
function gitprotectConfigured() {
    if ! [[ -d "$gnupgHome" ]]; then
        echo "ERROR: Current directory has not been configured for use with $program." >&2
        echo "You can initialise the current directory with command '$program init'." >&2
        echo "Working directory is '$(pwd)'." >&2
        return 1
    fi

    return 0
}

#
# Error codes.
#
ERR_NOTINGIT=10
ERR_NOCONFIG=11
ERR_NOKEYARG=12
ERR_NODECRYPTDIR=13
ERR_NORECIPIENTS=14

# If no arguments were given, just show usage help.
if [[ -z $1 ]]; then
    usage
    exit 0
fi

# Parse the arguments
while getopts "vh" opt; do
    case "$opt" in
        v) version
           exit 0;;
        h) usage
           exit 0;;
        *) usage
           exit 1;;
    esac
done
i=$OPTIND
shift $(($i-1))

# Read the command parameter.
command="$1"
shift

# Make sure the command is run from within a git repository.
inGit || exit "$ERR_NOTINGIT"

# Set-up some default values.
gnupgHome="$(pwd)/.gnupg"
gnupgArgs=("--homedir" "$gnupgHome" "--batch")

if [[ $command == "init" ]]; then
    if [[ -d $gnupgHome ]]; then
        echo "Directory already set-up." >&2
        exit 0
    fi

    # Create the local .gnupg directory.
    mkdir "$gnupgHome"
    chmod 700 "$gnupgHome"

    # Initialise the GnuPG files in local directory.
    gpg2 "${gnupgArgs[@]}" --list-keys 2>/dev/null

    # Set-up a .gitignore file that will exclude some temporary files from being
    # tracked, as well as decrypted files.
    cat <<EOF >> .gitignore
# BEGIN gitprotect.sh
.gnupg/pubring.gpg~
.gnupg/random_seed
.gnupg/secring.gpg
decrypted/
# END gitprotect.sh
EOF
    # Add the empty keyring and gitignore file to the index so they can be
    # committed by the user.
    git add .gnupg/
    git add .gitignore
    cat <<EOF
$program has set-up the repository directory for encryption. Before proceeding,
please commit the changes. The commit includes empty public and trust keryings for
GnuPG, and gitignore file that prevents inclusion of decrypted files and
temporary GnuPG files.

Before proceeding with the commit, verify the changes with:

git status .
git diff --staged .

After you have verfied the changes, commit the changes with (you may specify
alternative message):

git commit .gnupg .gitignore -m "Configured directory for use with gitprotect.sh"

EOF
elif [[ $command == "addkey" ]]; then
    gitprotectConfigured || exit "$ERR_NOCONFIG"

    # At least one key has to be provided.
    if [[ "${#@}" == 0 ]]; then
        echo "ERROR: At least one key file or identifier must be specified" >&2
        exit "$ERR_NOKEYARG"
    fi

    # Process all the keys specified.
    for key in "$@"; do
        # First try accessing a file by the given key name. Otherwise treat it
        # as key identifier.
        if [[ -f $key ]]; then
            if ! gpg2 "${gnupgArgs[@]}" --import "$key"; then
                echo "ERROR: Failed to add key from file '$key'." >&2
            fi
        else
            if ! gpg2 --batch --list-keys "$key" >/dev/null 2>&1; then
                echo "WARN: Key with identifier '$key' not found in user's GnuPG keyring. Skipping." >&2
            else
                ! gpg2 --batch --armor --export "${key}!" | gpg2 "${gnupgArgs[@]}" --import
                if [[ ${PIPESTATUS[0]} != 0 ]]; then
                    echo "ERROR: Failed to add key with identifier '$key')." >&2
                fi
            fi
        fi
    done
elif [[ $command = "rmkey" ]]; then
    gitprotectConfigured || exit "$ERR_NOCONFIG"

    # At least one key has to be provided.
    if [[ "${#@}" == 0 ]]; then
        echo "ERROR: At least one key file or identifier must be specified" >&2
        exit "$ERR_NOKEYARG"
    fi

    # Process all the keys specified.
    for key in "$@"; do
        if ! gpg2 "${gnupgArgs[@]}" --list-key "$key" 2>/dev/null; then
            echo "WARN: Key with identifier '$key' not found in git repository directory's GnuPG keyring. Skipping" >&2
        elif ! gpg2 "${gnupgArgs[@]}" --yes --delete-key "$key"; then
            echo "ERROR: Failed to remove the key with identifier '$key'." >&2
        fi
    done
elif [[ $command = "listkeys" ]]; then
    gitprotectConfigured || exit "$ERR_NOCONFIG"
    gpg2 "${gnupgArgs[@]}" --list-public-keys --keyid-format long
elif [[ $command = "encrypt" ]]; then
    gitprotectConfigured || exit "$ERR_NOCONFIG"

    # Verify that the directory with unencrypted files exists.
    if [[ ! -d "decrypted/" ]]; then
        echo "ERROR: Nothing to encrypt. sub-directory 'decrypted' does not exist."
        exit "$ERR_NODECRYPTDIR"
    fi

    # Set-up the list of recipients. Read the information about each public
    # sub-key from the local keyring.
    while read key_validity key_id key_capabilities; do
        # Only use non-expired sub-keys that have encryption capability.
        if [[ $key_validity != e && $key_capabilities =~ .*e.* ]]; then
            recipients+=("$key_id")
            recipientArgs+=("-r" "$key_id!")
        fi
    done < <(gpg2 "${gnupgArgs[@]}" --list-public-keys --with-colons | grep '^sub' | awk 'BEGIN { FS = ":" } ; { print $2, $5, $12 }')

    # Make sure that we have at least a single recipient.
    if [[ "${#recipients[@]}" == 0 ]]; then
        echo "ERROR: No suitable recipients were found in the keyring. Did you forget to add keys?" >&2
        exit "$ERR_NORECIPIENTS"
    fi

    # Encrypt every file from the decrypted sub-directory.
    while read decryptedFile; do
        filename=$(basename "$decryptedFile")
        encryptedFile="./${filename}.gpg"
        checksumFile="decrypted/.${filename}.sha256"

        # If the encrypted file is present, fetch list of keys that were used to
        # encrypt it, and list of keys in the current keyring so we can compare
        # them later on.
        if [[ -f $encryptedFile ]]; then
            currentFileRecipients=$(gpg2 --status-fd 1 --homedir "$gnupgHome" --quiet --batch --list-only --decrypt "$encryptedFile" | grep ENC_TO | sed -e 's/.*ENC_TO //;s/ .*//' | sort -u)
            newFileRecipients=$(echo "${recipients[@]}" | tr ' ' '\n' | sort -u)
        fi

        # If an encrypted file exists, and its recipient list is outdated,
        # re-encrypt the decrypted file to get the recipients into sync.
        if [[ -f $encryptedFile && $currentFileRecipients != $newFileRecipients ]]; then
            echo "INFO: Encrypting file '$decryptedFile' due to differing recipients in keyring and current encrypted file."
            cat "$decryptedFile" | gpg2 --trust-model always "${gnupgArgs[@]}" \
                --armor "${recipientArgs[@]}" --encrypt > "$encryptedFile"
            sha256sum "$decryptedFile" > "$checksumFile"
        # If the checksum file exists, then verify it. This way we detect if
        # decrypted file has been changed in any way since it has been
        # decrypted. We should skip unchanged files.
        elif [[ -f $checksumFile ]] && sha256sum --quiet -c "$checksumFile" > /dev/null 2>&1; then
            echo "INFO: File $decryptedFile doesn't seem to have been changed. Skipping."
        # The file was changed, so we need to encrypt new version of it.
        else
            echo "INFO: Encrypting new version of file '$decryptedFile'."
            cat "$decryptedFile" | gpg2 --trust-model always "${gnupgArgs[@]}" \
                --armor "${recipientArgs[@]}" --encrypt > "$encryptedFile"
            sha256sum "$decryptedFile" > "$checksumFile"
        fi
    done < <(find "decrypted/" -maxdepth 1 -type f ! -name '.*.sha256')
elif [[ $command = "decrypt" ]]; then
    gitprotectConfigured || exit "$ERR_NOCONFIG"

    # Create the sub-directory that will contain the decrypted data.
    mkdir -p "decrypted/"

    # Process each GnuPG-encrypted file.
    while read filePath; do
        filename=$(basename "$filePath" ".gpg")
        checksumFile="decrypted/.${filename}.sha256"

        # If the checksum file exists, and it does not match, the destination
        # file has been modified, so refuse to overwrite it.
        if [[ -f $checksumFile ]] && ! sha256sum --quiet -c "$checksumFile" > /dev/null 2>&1; then
            echo "ERROR: Current decrypted file 'decrypted/$filename' seems to have been modified since it was last decrypted."
        # Decrypt the file. If decryption has failed, we've ended-up with empty
        # file we need to remove.
        elif ! gpg2 --quiet --decrypt "$filePath" > "decrypted/$filename"; then
            echo "ERROR: Failed to decrypt file '$filePath'. No private key available." >&2
            rm "decrypted/$filename"
        # Create a new checksum file for checking if file had been changed by
        # user or not.
        else
            sha256sum "decrypted/$filename" > "decrypted/.${filename}.sha256"
        fi
    done < <(find . -maxdepth 1 -name '*.gpg')
else
    echo "ERROR: Unsupported command '$command'" >&2
fi