#!/usr/bin/env bash ############################################################################## # # check-commit-signature # ---------------------- # A server-side update git hook for checking the GPG signature of a pushed # commit. # # To enable this hook, rename this file to "update". # # Config # ------ # hooks.allowunsignedcommits # This boolean sets whether unsigned tags, and merges with unsigned commits # into master, are allowed. By default, they are not allowed. # hooks.allowunsignedtags # This boolean sets whether unsigned tags are allowed. By default, # they are not allowed. # hooks.allowunverifiedsigs # This boolean sets whether commits/tags signed by # unverified/untrusted keys are allowed. By default, they are not allowed. # hooks.allowcommitsonmaster # This boolean sets whether non-merge commits are allowed on master. By # default, these are not allowed. # hooks.allowhotfixonmaster # The boolean sets whether branches beginning with hotfix-* are allowed on # master. NOT YET IMPLEMENTED. CURRENTLY IGNORED. # hooks.allowunannotated # This boolean sets whether unannotated tags will be allowed into the # repository. By default they won't be. # hooks.allowdeletetag # This boolean sets whether deleting tags will be allowed in the # repository. By default they won't be. # hooks.allowmodifytag # This boolean sets whether a tag may be modified after creation. By default # it won't be. # hooks.allowdeletebranch # This boolean sets whether deleting branches will be allowed in the # repository. By default they won't be. # hooks.denycreatebranch # This boolean sets whether remotely creating branches will be denied # in the repository. By default this is allowed. # hooks.restrictmergetomaster # This boolean sets whether restrictions on merging to master should be # enforced. By default there won't be. # # @author Isis Agora Lovecruft, 0x2cdb8b35 # @date 10 March 2013 # @version 0.0.1 ############################################################################## ## server config ################## ## $1 is the git ref which is being revised ## $2 is the last HEAD ## $3 is the HEAD commit of the series of commits being applied ref=$1 rev_old=$2 rev_new=$3 ## This file has been modified to run off a .gitconfig file in the root of the repo. ## If this file doesn't exist, the hook will exit cleanly. ## Because of this the hook can be included globally and only executes on projects that need it. ## This way, you can add it as a global hook in i.e. Gitlab, and can be configured per-project. ## ## Be aware that this assumes that all collaborators are trustworthy and might ## cause security issues if this isn't the case. ## It's recommended to at least enable the GPG whitelist when using this hook. git cat-file -e "$rev_old:.gitconfig" &>/dev/null || exit 0; allowunsignedcommits=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowunsignedcommits) allowunsignedtags=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowunsignedtags) allowunverifiedsigs=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowunsignedtags) allowcommitsonmaster=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowcommitsonmaster) allowhotfixonmaster=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowhotfixonmaster) allowdeletebranch=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowdeletebranch) denycreatebranch=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.denycreatebranch) allowunannotated=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowunannotated) allowdeletetag=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowdeletetag) allowmodifytag=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.allowmodifytag) restrictmergetomaster=$(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --bool hooks.restrictmergetomaster) echo ".gitconfig file in repo found, running update hook." git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --list ## Whitelisted GPG key fingerprints are stored in .gitconfig in the root of the repo. ## To add keys, you either edit the file directly: ## ## $ git config --file .gitconfig -e ## [gpgkeys] ## whitelist = "0A6A 58A1 4B59 46AB DE18 E207 A3AD B67A 2CDB 8B35" # Isis ## whitelist = "0C83 9FFC C5A6 5925 D91B F16C FF52 7312 2C52 C4E4" # Sebastiaan ## ## Or you can add them through the git config command: ## ## $ git config --file .gitconfig gpgkeys.whitelist --add '0A6A 58A1 4B59 46AB DE18 E207 A3AD B67A 2CDB 8B35' \ ## --add '0C83 9FFC C5A6 5925 D91B F16C FF52 7312 2C52 C4E4' ## ## Do note that you cannot add a key description if you do it this way. ## ## Remember to add the public keys to the server, too, or verification will fail. ## If allowunverifiedsigs=false, make sure the public keys are signed by a trusted key as well. readarray -t collaborators < <(git cat-file blob "$rev_old:.gitconfig" | git config --file /dev/stdin --get-all gpgkeys.whitelist) if [[ "$allowunsignedcommits" != true ]] && [[ "${#collaborators[@]}" -eq 0 ]]; then echo "WARNING: allowunsignedcommits is false but no authorized keys have" echo "WARNING: been added yet. Skipping module." allowunsignedcommits=true fi declare -A trusted_keys for collab in "${!collaborators[@]}"; do ## As per git version v2.10.0-rc1, git now shows longids by default fpr="${collaborators["$collab"]}" longid=$(echo "$fpr" | awk '{print $7$8$9$10}' ) trusted_keys["$collab"]="$longid" done is_allowed_signer() { signing_keyid=$1 if [ -z "$signing_keyid" ]; then echo "*** Error: is_allowed_signer(): No key ID supplied" >&2 return 1 fi fpr_signing_keyid=$(gpg --fingerprint "$signing_keyid" | \ grep -o -E "([0-9A-F]{4}[[:space:]]{0,2}){10}") allowed_keyid=1 for collab in "${!trusted_keys[@]}"; do ## XXX the $keyid is currently only a short key id ## because git doesn't give us a way to ask for a ## long keyid keyid="${trusted_keys["$collab"]}" if [ "$signing_keyid" = "$keyid" ]; then ## check that the fingerprint of the key that ## gave us a good signature matches the ## hardcoded ones: if [ "$fpr_signing_keyid" = "${collaborators["$collab"]}" ]; then allowed_keyid=0 break fi fi done return $allowed_keyid } ## the following is the short reference name for tags, i.e. 'v0.1.2' in ## lieu of 'refs/tags/v0.1.2': short_ref=${ref##refs/tags/} ## a hash full of zeroes is how Git represents "nothing" zero="0000000000000000000000000000000000000000" ## get all new commits on branch ref, even if it's a new branch if [ "$rev_old" = "$zero" ]; then # list everything reachable from rev_new but not any heads span=$(git rev-list $rev_new --not --branches='*') else span=$(git rev-list ^$rev_old $rev_new) fi if [ "$allowcommitsonmaster" != "true" ]; then ## the only commits allowed on master are merges which have rev_old as ## a direct parent of rev_new ## ## we have to check this in a separate step, since "git rev-list ## ^rev_old rev_new" on master at the merge of a feature branch will ## also give us all the commits on the feature branch, and we won't ## have enough context while looping through those commits to do this ## check properly if [ "$ref" = "refs/heads/master" ]; then ## if rev_new isn't a merge, it will only have one parent and ## this "git show" command will return nothing parents=$(git show --merges --no-patch --format=%P $rev_new) old_is_parent_of_new=false for parent in $parents ; do if [ "$rev_old" = "$parent" ]; then old_is_parent_of_new=true break fi done if [ "$old_is_parent_of_new" != "true" ]; then echo "*** Master only accepts merges of feature branches" >&2 exit 1 fi fi fi if [ -z "$span" ]; then ## rev_new pointed to something considered by "git rev-list" to be ## already in the commit graph, which could be a commit (if we're ## adding a lightweight tag) or a tag object (if we're adding an ## annotated tag), since "git rev-list" doesn't consider the tag ## object to be separate from the commit it points to type=$(git cat-file -t $rev_new) case $type in commit) ## lightweight tag if [ "$allowunsignedtags" != "true" -o "$allowunannotated" != "true" ]; then echo "*** The un-annotated tag $short_ref is not allowed in this repository" >&2 echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 exit 1 fi ;; tag) ## annotated tag if [ "$rev_old" != $zero -a "$allowmodifytag" != "true" ]; then echo "*** Tag $short_ref already exists." >&2 echo "*** Modifying a tag is not allowed in this repository." >&2 exit 1 else if [ "$allowunsignedtags" != "true" ]; then result=$(git verify-tag $rev_new 2>&1 >/dev/null) if ! grep '^gpg: Good signature' <<< "$result"; then echo "*** Tag $short_ref is not signed" >&2w exit 1 fi signing_keyid=$(<<<"$result" grep "^gpg: Signature made" | \ grep -o -E "key ID [0-9A-Fa-f]{16}" | \ cut -d ' ' -f 3 ) if is_allowed_signer $signing_keyid; then echo "*** Good signature on tag $short_ref by signing key $signing_keyid" >&2 else echo "*** Rejecting tag $short_ref due to lack of a valid GPG signature" >&2w exit 1 fi fi fi ;; *) echo "*** No new commits, but the pushed ref $ref is a \"$type\" instead of a tag? I'm confused." >&2 exit 1 ;; esac fi ## for all the commits in the series, check the type of the commit against the ## commit directly before it: rev_cur=$rev_old ## set the current rev to the previous HEAD for commit in $span ; do ## check that the current revison object is a hexidecimal hash of length 40 check_rev=$(git rev-parse --verify "$commit") if ! grep -q -E '^[0-9A-Fa-f]{40}$' <<< $check_rev; then echo "*** Commit hash is not 40 hex characters" >&2 exit 1 fi ## get the commit type of the current rev: ## a commit with a hash full of zeros is a deletion of a ref if [ "$commit" = "$zero" ]; then commit_type=delete else if [ "$rev_cur" = "$zero" ]; then ## there was no previous commit to check against, ## so this is the first commit on a branch commit_type=$(git cat-file -t "$commit") else merge=$(git rev-list -n 1 --merges "$rev_cur".."$commit") if test -n "$merge"; then commit_type=merge else commit_type=$(git cat-file -t "$commit") fi fi fi ## the following returns non-null if $rev_cur is originating from branches ## beginning with the name "devel": is_from_develop=$(git branch --contains "$commit" | grep devel ) ## the following returns non-null if $rev_cur is originating from branches ## beginning with the name "release": is_from_release=$(git branch --contains "$commit" | grep release ) ## the following returns non-null if $rev_cur is a signed tag: is_signed_tag=$(git tag --verify "$ref" 2>&1 >/dev/null | grep '^gpg:') ## the following returns non-null if $rev_cur has a signature, and gpg reports ## the signature is good: verify_sig=$(git verify-commit --raw "$commit" 2>&1) has_good_sig=$? if [ $has_good_sig -eq 0 ] && [ "$allowunverifiedsigs" != "true" ]; then has_verified_sig=false echo "$verify_sig" | grep -e '^\[GNUPG:\] TRUST_UNDEFINED' >/dev/null || has_verified_sig=true fi ## the following extracts the signing keyid (either short or long) from the ## signature on $rev_cur: signing_keyid=$(git show --no-patch --format=%H --show-signature "$commit" | \ grep -oE "^gpg:\s+ using .* key [0-9A-Fa-f]{16}" | \ sed -e 's/^.* \([0-9A-Fa-f]\{16\}$\)/\1/' ) fpr_signing_keyid=$(gpg --fingerprint "$signing_keyid" 2>/dev/null | \ grep -o -E "([0-9A-F]{4}[[:space:]]{0,2}){10}") case "$ref","$commit_type" in refs/heads/*,commit) ## commit on any branch if [ "$rev_old" = "$zero" -a "$denycreatebranch" = "true" ]; then echo "*** Creating a branch is not allowed in this repository" >&2 exit 1 fi if [ "$allowunsignedcommits" != "true" ]; then if [ "$has_good_sig" -ne 0 ]; then echo "*** Bad signature on commit $commit" >&2 exit 1 fi if [ "$allowunverifiedsigs" != "true" ] && [ "$has_verified_sig" != "true" ]; then echo "has_verified_sig $has_verified_sig" echo "*** Unverified signature on commit $commit" >&2 exit 1 fi if is_allowed_signer $signing_keyid; then echo "*** Good signature on commit $commit by signing key $signing_keyid" >&2 else echo "*** Key $signing_keyid is not allowed to sign commit $commit" >&2 exit 1 fi fi ;; refs/heads/master,merge) ## only allow merges to master from release-* and develop/* if [ "$restrictmergetomaster" = "true" ] && test -z "$is_from_develop" -a -z "$is_from_release"; then echo "*** Branch master only takes merge commits originating from develop/* or release-* branches" >&2 exit 1 else if [ "$allowunsignedcommits" != "true" ]; then if [ -n "$has_good_sig" -a -n "$signing_keyid" ]; then if is_allowed_signer $signing_keyid; then echo "*** Good signature on merge $commit by signing key $signing_keyid" >&2 else echo "*** Key $signing_keyid is not allowed to sign merge $commit" >&2 exit 1 fi else echo "*** Merges must be signed with an authorised key" >&2 exit 1 fi fi fi ;; refs/heads/*,merge) ## merge into non-master branch if [ "$allowunsignedcommits" != "true" ]; then if [ -n "$has_good_sig" -a -n "$signing_keyid" ]; then if is_allowed_signer $signing_keyid; then echo "*** Good signature on merge $commit by signing key $signing_keyid" >&2 else echo "*** Key $signing_keyid is not allowed to sign merge $commit" >&2 exit 1 fi else echo "*** Merges must be signed with an authorised key" >&2 exit 1 fi fi ;; refs/heads/master,delete) # delete branch if [ "$allowdeletebranch" != "true" ]; then echo "*** Deleting master is not allowed in this repository" >&2 exit 1 fi ;; refs/heads/master,*) ## kill it with fire echo "*** Branch master only takes merge commits originating from develop/* or release-* branches" >&2 exit 1 ;; refs/tags/*,delete) ## delete tag if [ "$allowdeletetag" != "true" ]; then echo "*** Deleting a tag is not allowed in this repository" >&2 exit 1 fi ;; refs/remotes/*,commit) ## tracking branch ;; refs/remotes/*,delete) ## delete tracking branch if [ "$allowdeletebranch" != "true" ]; then echo "*** Deleting a tracking branch is not allowed in this repository" >&2 exit 1 fi ;; *) ## Anything else (is there anything else?) echo "*** Unknown type of update to ref $ref of type $commit_type " >&2 exit 1 ;; esac ## increment the current rev to the $commit we just checked: rev_cur=$commit done