#!/bin/sh
##########################################################################
#
# This script will attempt to sign your commit using one of the
# authentication methods in your mntner record.
#
# If the script fails to work, PRs for fixes are always welcome
# and you can always sign your commit manually as detailed in the
# DN42 wiki: https://dn42.dev/howto/Registry-Authentication
#
# do './sign-my-commit --help' to get usage information
#
##########################################################################

usage()
{
    cat <<EOF

This script can automatically sign commits using supported SSH or PGP
authentication methods. Remember to push the new signature to the server
after signing (using 'git push --force' or the --push option).

The script will attempt to use the first available auth method, or you
can force it to use PGP or SSH methods with the relevant options.

For SSH signatures the script may be able to find your key via ssh-agent
otherwise you must use the --key option to tell it which key to use.

Usage: $0 [options] YOUR-MNTNER-MNT

Generic options:
  --pgp,             force signature to use a PGP key
  --ssh,             force signature to use an SSH key
  --push,            force push the result after signature and verification
  --verify,          check an existing signature on the latest commit
  --commit <hash>,   check the signature on a specific commit
  --force,           override some checks and continue on errors
  --help,            display this message

PGP specific options:
  --print <fprint>,  specify fingerprint of GPG key to use if you
                     don't want to use the first available key

SSH specific options:
  --key <file>,      specify SSH key file to use if not using ssh-agent
                     or want to use a different key than the first available
                     (this can be a public or private keyfile)
  --method <type>,   either 'git' or 'comment' to force SSH signatures
                     to use a specific method, defaults to 'git'

Some parameters can also be set via environment variables,
see the defaults section in the script for more details.

EOF

}

##########################################################################
# defaults

DO_PUSH=${DO_PUSH:-0}
DO_SQUASH=${DO_SQUASH:-1}
AUTH_METHOD=${AUTH_METHOD:-''}
MNTNER=${MNTNER:-''}
SSH_KEYFILE=${SSH_KEYFILE:-''}
SSH_METHOD=${SSH_METHOD:-'git'}
GPG_PRINT=${GPG_PRINT:-''}
VERIFY_ONLY=${VERIFY_ONLY:-0}
COMMIT_SHA=${COMMIT_SHA:-''}
FORCE=${FORCE:-0}

##########################################################################
# parse arguments

while [ -n "$1" ]
do
    case "$1" in

        --pgp)
            AUTH_METHOD='pgp'
            ;;
        --ssh)
            AUTH_METHOD='ssh'
            ;;
        --push)
            DO_PUSH=1
            ;;
        --no-squash)
            DO_SQUASH=0
            ;;
        --verify)
            VERIFY_ONLY=1
            ;;
        --commit)
            shift
            VERIFY_ONLY=1
            COMMIT_SHA="$1"
            ;;
        --key)
            shift
            SSH_KEYFILE="$1"
            ;;
        --method)
            shift
            SSH_METHOD="$1"
            ;;
        --print)
            shift
            GPG_PRINT="$1"
            ;;
        --force)
            FORCE=1
            ;;
        --help)
            usage
            exit 0
            ;;
        *)
            if [ -z "$MNTNER" ]
            then
                MNTNER="$1"
            else
                >&2 echo "ERROR: Unknown option: $1"
                >&2 usage
                exit 1
            fi
            ;;

    esac

    shift
done

##########################################################################
# perform some initial sanity checks

# check working directory
if [ ! -d '.git' ] && [ ! -d 'data/mntner' ]
then
    >&2 echo 'ERROR: This script must be run in the root directory of a registry clone'
    exit 1
fi

# fill in the last commit if it wasn't specified already
if [ -z "$COMMIT_SHA" ]
then
    COMMIT_SHA=$(git log -n 1 --format=format:%H)
fi

# reset local git configuration
git config --local --unset gpg.format
git config --local --unset user.signingkey
git config --local --unset gpg.ssh.allowedSignersFile

# if verifying only, try to guess some info from the existing sig
if [ "$VERIFY_ONLY" -eq 1 ]
then
    if [ -z "$MNTNER" ]
    then
        MNTNER=$(git log "$COMMIT_SHA" -n 1 --format=format:%B | \
                     grep '^### mntner:' | \
                     cut -d':' -f2 | tr -d ' ' | tail -n 1)
        if [ -n "$MNTNER" ]
        then
            echo "Found mntner $MNTNER from signature"
        fi
    fi

    if [ -z "$AUTH_METHOD" ]
    then
        AUTH_METHOD=$(git log "$COMMIT_SHA" -n 1 --format=format:%B | \
                          grep '^### method:' | \
                          cut -d':' -f2 | tr -d ' ' | tail -n 1)
        if [ -n "$AUTH_METHOD" ]
        then
            echo "Using $AUTH_METHOD auth method from signature"
        fi
    fi
fi

# check that a mntner has been specified and exists
if [ -z "${MNTNER}" ]
then
    usage
    exit 1
fi

if [ ! -f "data/mntner/${MNTNER}" ]
then
    >&2 echo "ERROR: mntner '${MNTNER}' not found"
    exit 1
fi

# figure out the git version
gitv_major=$(git --version | cut -d' ' -f3 | cut -d'.' -f1)
gitv_minor=$(git --version | cut -d'.' -f2)

# the script needs at least git 2.5
if { [ "$gitv_major" -eq 2 ] && [ "$gitv_minor" -lt 5 ]; } || \
       [ "$gitv_major" -lt 2 ]
then
    >&2 echo 'ERROR: This script requires a git version 2.5'
    >&2 echo '---'
    >&2 git --version
    exit 1
fi

# if signing, check the repo is ready
if [ "$VERIFY_ONLY" -ne 1 ]
then

    # check for untracked or uncommitted changes
    if [ -n "$(git status --porcelain)" ]
    then
        >&2 echo 'ERROR: git worktree has unstaged or uncommitted changes'
        >&2 echo 'This script can only be run once your commit is completed'
        >&2 echo '---'
        >&2 git status
        exit 1
    fi

    # check that the commit has been squashed
    if [ "$DO_SQUASH" -eq 1 ]
    then
        if ! ./squash-my-commits --verify
        then
            >&2 echo 'ERROR: Ensure your commits are squashed before signing'
            >&2 echo 'Run the included script: ./squash-my-commits'
            exit 1
        fi
    fi

    # check for an existing signature
    if git log -n 1 --format=format:%B 2>&1 | grep '^### DN42 Signature' > /dev/null
    then
        >&2 echo 'ERROR: Detected ### DN42 Signature header in commit comment'
        >&2 echo 'ERROR: The commit appears to be signed already'
        if [ "$FORCE" -ne 0 ]
        then
            >&2 echo '!!! ignoring errors, continuing'
        else
            >&2 echo '---'
            >&2 git log -n 1 --show-signature
            >&2 echo '---'
            >&2 echo 'If you want to re-try signing the commit then you must either'
            >&2 echo 're-run this script again using the --force option, or'
            >&2 echo 'manually remove the "### DN42 Signature" header from the commit'
            >&2 echo 'comment (e.g. using git commit --amend)'
            exit 1
        fi
    fi

fi

##########################################################################
# helper functions

# guess a signature method based on the first auth attribute in a MNTNER
guess_mntner_method()
{
    method=$(grep '^auth:' "data/mntner/${MNTNER}" | head -n 1 | cut -c21- | cut -d' ' -f1)
    case "$method" in
        pgp-fingerprint|PGPKEY-*)
            echo 'pgp'
            ;;
        ssh-*|sk-ssh-*|ecdsa-*|sk-ecdsa-*)
            echo "ssh"
            ;;
        '')
            >&2 echo "ERROR: Unable to find any auth attributes for $MNTNER"
            exit 1
            ;;
        *)
            >&2 echo "ERROR: Unknown or unimplemented auth method '$method'"
            >&2 echo 'Check the auth attribute is actually supported '
            >&2 echo 'or specify the signature type manually.'
            exit 1
            ;;
    esac
}

##########################################################################
#
# PGP Section - functions for signing and verify PGP signatures
#
##########################################################################
# PGP Helper functions

# create a list of authorised PGP fingerprints
get_pgp_prints()
{
    pgp_prints=$(mktemp)

    # cut auth methods from mntner
    grep '^auth:' "data/mntner/${MNTNER}" | cut -c21- | \
        while read -r auth_method auth_data
        do
            case "$auth_method" in
                pgp-fingerprint)
                    # use the fingerprint directly
                    echo "$auth_data" | \
                        tr '[:lower:]' '[:upper:]' >> "$pgp_prints"
                    ;;
                PGPKEY-*)
                    if [ ! -f "data/key-cert/$auth_method" ]
                    then
                        >&2 echo "ERROR: failed to find key-cert object: $auth_method"
                        rm "$pgp_prints"
                        exit 1
                    fi

                    # get the fingerprint from key-cert file
                    grep '^fingerpr:' "data/key-cert/$auth_method" | \
                        cut -c21- | tr -d ' ' | \
                        tr '[:lower:]' '[:upper:]' >> "$pgp_prints"
                    ;;
            esac
        done

    if [ ! -s "$pgp_prints" ]
    then
        >&2 echo "ERROR: failed to find any pgp fingerprints for $MNTNER"
        rm "$pgp_prints"
        exit 1
    fi

    echo "$pgp_prints"
}

##########################################################################
# PGP signing function

sign_pgp()
{
    # check first if there is already a signature
    if git log -n 1 --show-signature | grep "^gpg" > /dev/null 2>&1
    then
        >&2 echo 'ERROR: Found existing git signature'
        >&2 echo 'ERROR: The last commit appears to be signed already'
        if [ "$FORCE" -ne 0 ]
        then
            >&2 echo '!!! ignoring errors, continuing'
        else
            >&2 echo "---"
            >&2 git log -n 1 --show-signature
            >&2 echo "---"
            >&2 echo "You can use the --force option for re-try signing"
            exit 1
        fi
    fi

    # if the fingerprint wasn't specified, obtain from the MNTNER
    if [ -z "$GPG_PRINT" ]
    then
        pgp_prints=$(get_pgp_prints)
        GPG_PRINT=$(head -n 1 "$pgp_prints")
        rm "$pgp_prints"
    fi

    echo "PGP signing using fingerprint: $GPG_PRINT"

    # configure local git for pgp signing
    git config --local --unset gpg.format
    git config --local user.signingKey "$GPG_PRINT"

    # create a new comment with some additional metadata
    comment="$(git log -n 1 --format=format:%B)

### DN42 Signature
### method: pgp
### mntner: $MNTNER
"

    # PGP signing is straightforward
    if ! git commit --amend --no-edit -S -m "$comment"
    then
        >&2 echo "ERROR: failed to sign commit"
        exit 1
    fi

    # update the COMMIT_SHA for the verification phase
    COMMIT_SHA=$(git log -n 1 --format=format:%H)
}

##########################################################################
# verify PGP signature

verify_pgp()
{
    echo 'Verifying PGP signature'

    # requires git 2.5
    if ! git verify-commit "$COMMIT_SHA"
    then
        >&2 echo 'ERROR: failed to verify PGP signature'
        exit 1
    fi
    echo ' - PGP signature verified ok'

    # create a list of authorised pgp fingerprints
    valid_prints=$(get_pgp_prints)

    # extract the fingerprint of the key that was successful
    prints=$(git verify-commit --raw "$COMMIT_SHA" 2>&1 | \
                 grep "VALIDSIG" | cut -f3,12 -d' ')
    for print in $prints
    do
        if grep "$print" "$valid_prints" > /dev/null 2>&1
        then
            echo "Matched fingerprint with auth attribute for $MNTNER"
            echo 'Successfully verified PGP signature'
            rm "$valid_prints"
            return
        fi
    done

    >&2 echo "ERROR: unable to match key fingerprint with mntner: $MNTNER"
    rm "$valid_prints"
    exit 1
}


##########################################################################
#
# SSH Section - functions for signing and verify SSH signatures
#
##########################################################################
# SSH helper functions

# return only ssh auth methods for mntner
filter_ssh_auths()
{
    grep '^auth:' "data/mntner/${MNTNER}" | cut -c21- | \
        while read -r line
        do
            case "$line" in
                ssh-*|sk-ssh-*|ecdsa-*|sk-ecdsa-*)
                    echo "$line"
                    ;;
            esac
        done
}

# create an allowed signers file using the mntner auth attributes
get_allowed_signers()
{
    allowed=$(mktemp)
    filter_ssh_auths | sed "s/^/${MNTNER} /" > "$allowed"
    echo "$allowed"
}

# try and find a suitable keyfile that we can sign with
check_keyfile()
{
    pubkeyfile=''

    # guess the public key if a keyfile wasn't specified
    if [ -z "$SSH_KEYFILE" ]
    then
        pubkeyfile=$(mktemp)

        echo "Obtaining public key from $MNTNER auth attributes"

        # get the public key from mntner auth records
        filter_ssh_auths | head -n 1 > "$pubkeyfile"
        if [ ! -s "$pubkeyfile" ]
        then
            >&2 echo 'ERROR: Unable to auto determine SSH public key'
            >&2 echo 'Try specifying the key directly using --key'
            rm "$pubkeyfile"
            exit 1
        fi

        # check if the pubkey is available in agent
        pubkey=$(tr -s ' ' < "$pubkeyfile" | cut -d' ' -f1,2)
        if ssh-add -L | grep "^$pubkey" > /dev/null 2>&1
        then
            # key was found in agent ok
            SSH_KEYFILE="$pubkeyfile"
        else
            # no key found in agent, clean up the keyfile first
            rm "$pubkeyfile"
            pubkeyfile=''

            if [ -d "${HOME}/.ssh" ]
            then
                # as a last resort, try scanning the 'usual' ssh
                # directory to find the key in there

                SSH_KEYFILE=$(grep -l "^$pubkey" "${HOME}"/.ssh/*.pub)
            fi

            if [ -n "$SSH_KEYFILE" ]
            then
                >&2 echo "Found SSH key in: $SSH_KEYFILE"
            else
                # all attempts failed
                >&2 cat <<EOF
ERROR: Unable to identify public key in ssh-agent or home directory
- When auto detecting the public key from you mntner object it
- is required that the private key is available in ssh-agent or
- in your ${HOME}/.ssh/ directory.
- Please add your key to ssh-agent or use the --key option to
- specify where the private key is directly.
---
Pubkey: $pubkey
EOF
                exit 1
            fi
        fi
    fi

    # try to validate the public key
    if ! pubkey=$(ssh-keygen -l -f "$SSH_KEYFILE")
    then
        >&2 echo "ERROR: $SSH_KEYFILE doesn't look like a valid SSH key"
        >&2 echo 'Try specifying the public or private key directly using --key'
        >&2 echo 'File contents:'
        >&2 cat "$SSH_KEYFILE"
        if [ -n "$pubkeyfile" ]; then rm "$pubkeyfile"; fi
        exit 1
    fi

    echo "Using: $pubkey"
}

##########################################################################
# SSH signing function

# SSH signature using git signatures
sign_ssh_git()
{
    check_keyfile

    # configure local git signing
    git config --local gpg.format ssh
    git config --local user.signingKey "$SSH_KEYFILE"

    # create a new comment with some additional metadata
    comment="$(git log -n 1 --format=format:%B)

### DN42 Signature
### method: ssh-git
### mntner: $MNTNER
"

    # the signature is now straightforward
    git commit --amend --no-edit -S -m "$comment"
    result=$?

    # clean up pubkeyfile first
    if [ -n "$pubkeyfile" ]; then rm "$pubkeyfile"; fi

    # was there an error ?
    if [ "$result" -ne 0 ]
    then
        >&2 echo 'ERROR: failed to sign commit'
        >&2 echo ' - Try specifying your key using --key'
        >&2 echo ' - or adding your key to ssh-agent'
        exit 1
    fi

    # update the COMMIT_SHA for the verification phase
    COMMIT_SHA=$(git log -n 1 --format=format:%H)
}


# SSH signature by adding in to the comment
sign_ssh_comment()
{
    check_keyfile

    # create the signature
    sig=$(echo "$COMMIT_SHA" | \
              ssh-keygen -Y sign -n dn42 -f "$SSH_KEYFILE")
    result=$?

    # clean up pubkeyfile first
    if [ -n "$pubkeyfile" ]; then rm "$pubkeyfile"; fi

    # check for errors
    if [ "$result" -ne 0 ]
    then
        >&2 echo 'ERROR: ssh-keygen signing failed'
        >&2 echo ' - Try specifying your key using --key'
        >&2 echo ' - or adding the key to ssh-agent'
        if [ -n "$pubkeyfile" ]; then rm "$pubkeyfile"; fi
        exit 1
    fi

    # create a comment including the signature
    comment="$(git log -n 1 --format=format:%B)

### DN42 Signature
### method: ssh
### mntner: $MNTNER
### text: $COMMIT_SHA
$sig
"

    # update the commit with the sig
    git commit --amend --no-edit -m "$comment"

    # update the COMMIT_SHA for the verification phase
    COMMIT_SHA=$(git log -n 1 --format=format:%H)
}

sign_ssh()
{
    # check for ssh-keygen signing capability
    if ! ssh-keygen -Y sign 2>&1 | grep 'missing namespace' > /dev/null
    then
        >&2 cat <<EOF
ERROR: This script requires the key signing capability from
OpenSSH ssh-keygen that was introduced in version 8.

If you are unable to upgrade ssh-keygen you must use one of the
manual signing methods detailed in the dn42 wiki:

https://dn42.dev/howto/Registry-Authentication

---
EOF
        >&2 ssh -V
        exit 1
    fi

    # if we have git >= 2.34 the commit can be git signed
    if [ "$SSH_METHOD" != 'comment' ]
    then
        if { [ "$gitv_major" -eq 2 ] && [ "$gitv_minor" -ge 34 ]; } || \
               [ "$gitv_major" -gt 2 ]
        then
            echo 'Detected git version >= 2.34, using git SSH signature'
            sign_ssh_git
            return
        else
            echo 'Detected git version < 2.34, cannot sign using git'
        fi
    fi

    echo 'Defaulting to comment based signature'
    sign_ssh_comment
}

##########################################################################
# verify SSH signature

# verify a git based SSH signature
verify_ssh_git()
{
    echo "Verifying SSH signature in git"

    # check git version
    if { [ "$gitv_major" -eq 2 ] && [ "$gitv_minor" -lt 34 ]; } || \
           [ "$gitv_major" -lt 2 ]
    then
        >&2 echo 'Detected git version < 2.34, unable to verify git signatures'
        >&2 echo '- Upgrade git to at least version 2.34'
        exit 1
    fi

    # create an allowed signers file and configure it in git
    allowed=$(get_allowed_signers)
    git config --local gpg.ssh.allowedSignersFile "$allowed"

    # signature can now be verified similar to pgp case

    # find the current commit hash
    git verify-commit "$COMMIT_SHA"
    result=$?

    # clean up allowed signers file before doing anything else
    git config --local --unset gpg.ssh.allowedSignersFile
    rm "$allowed"

    # did the signature successfully validate ?
    if [ "$result" -ne 0 ]
    then
        >&2 echo 'ERROR: failed to verify SSH signature'
        exit 1
    fi

    echo 'SSH signature verified ok'
}

# verify a comment based SSH signature
verify_ssh_comment()
{
    echo 'Verifying SSH signature comment'

    # create the allowed signers file
    allowed=$(get_allowed_signers)

    # extract the text that was signed from the git comment
    text=$(git log "$COMMIT_SHA" -n 1 --format=format:%B | \
               grep '^### text:' | cut -d':' -f2 | tr -d ' ')

    # also extract the SSH signature from the comment
    signature=$(mktemp)
    begin='-----BEGIN SSH SIGNATURE-----'
    end='-----END SSH SIGNATURE-----'
    git log "$COMMIT_SHA" -n 1 --format=format:%B | \
        sed "/^$begin\$/,/^$end\$/!d" > "$signature"

    # now we can verify the signature
    echo "$text" | ssh-keygen -Y verify -f "$allowed" \
                              -n dn42 -I "$MNTNER" -s "$signature"
    # grab the result and clean up before doing anything else
    result=$?
    rm "$allowed" "$signature"

    # did it work ?
    if [ "$result" -eq 0 ]
    then
        echo 'Successfully verified SSH sigature'
    else
        >&2 echo 'ERROR: signature verification failed'
        exit 1
    fi
}

# SSH verify wrapper
verify_ssh()
{
    # determine signature type from log comment
    method=$(git log "$COMMIT_SHA" -n 1 --format=format:%B | \
                 grep '^### method:' | cut -d':' -f2 | tr -d ' ' | tail -n 1)
    case "$method" in
        'ssh')
            verify_ssh_comment
            ;;
        'ssh-git')
            verify_ssh_git
            ;;
        '')
            echo 'WARNING: No dn42 signature found, attempting git based verification'
            verify_ssh_git
            ;;
        *)
            >&2 echo 'ERROR: commit does not appear to be signed by SSH'
            >&2 echo "Found signature method: $method"
            exit 1
            ;;
    esac
}

##########################################################################
#
# Script body - the script resumes here
#
##########################################################################

if [ -z "$AUTH_METHOD" ]
then
    if [ "$VERIFY_ONLY" -ne 1 ]
    then
        echo 'Attempting to guess signature method from mntner object'
        AUTH_METHOD=$(guess_mntner_method)
    fi
fi

# decide what to do
case "$AUTH_METHOD" in
    pgp)
        if [ "$VERIFY_ONLY" -ne 1 ]
        then
            echo 'Signing using PGP key'
            sign_pgp
        fi
        echo '---'
        verify_pgp
        ;;
    ssh|ssh-git)
        if [ "$VERIFY_ONLY" -ne 1 ]
        then
            echo 'Signing using SSH key'
            sign_ssh
        fi
        echo '---'
        verify_ssh
        ;;
    '')
        >&2 echo 'ERROR: Unable to automatically determine signing method'
        >&2 echo 'Use the --ssh or --pgp options to force a particular method'
        exit 1
        ;;
    *)
        >&2 echo "ERROR: Unknown or unimplemented auth method: $AUTH_METHOD"
        >&2 echo 'Use the --ssh or --pgp options to force a particular method'
        exit 1
        ;;
esac

##########################################################################
# all done, tidy up

if [ "$VERIFY_ONLY" -eq 1 ]
then
    exit 0
fi

# push changes if requested
if [ "$DO_PUSH" -eq 1 ]
then
    echo 'Force pushing changes'
    git push --force
else
    echo '---'
    echo 'Remember to push your changes using: git push --force'
    echo '---'
fi

exit 0

##########################################################################
# end of file
