GPG-signing Git Commits
We're working on migrating Zend Framework to Git. One issue we're trying to deal with is enforcing that commits come from CLA signees.
One possibility presented to us was the possibility of utilizing GPG signing of commit messages. Unfortunately, I was able to find little to no information on the 'net about how this might be done, so I started to experiment with some solutions.
The approach I chose utilizes git hooks,
specifically the commit-msg
hook client-side, and the pre-receive
hook
server-side.
Client-side commit-msg hook
The commit-msg
hook receives a single argument, the path to the temporary
file containing the commit message. This allows you to inspect it or modify it
prior to completing the commit. Like all git hooks, a non-zero exit status will
abort the commit.
My commit-msg
hook looks like the following:
###!/bin/sh
echo -n "GPG Signing message... ";
PASSPHRASE=$(git config --get hooks.gpg.passphrase)
if [ "" = "$PASSPHRASE" ];then
echo "no passphrase found! Set it with git config --add hooks.gpg.passphrase <passphrase>"
exit 1
fi
gpg --clearsign --yes --passphrase $PASSPHRASE -o $1.asc $1
mv $1.asc $1
echo "[DONE]"
This hook requires that you first add your GPG key's passphrase to your local git configuration, which can be done as follows:
$ git config --add hooks.gpg.passphrase "mySecret"
Once this hook is in place, all commit messages are then clear-signed, leading to commit logs that look like the following:
commit f921f0defb18f8a5218d5c3346693dbb4179920e
Author: Matthew Weier O'Phinney <somebody@example.com>
Date: Tue Mar 23 17:18:35 2010 -0400
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1
how now, brown cow
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
iEYEARECAAYFAkupMCsACgkQtUV5aSPtKdqERQCeN5taRATpB4/XJZiP9Vs5FVNY
PcoAn0OZbIIcn7nC01yxp9tY7HbxVVFu
=C/Ju
-----END PGP SIGNATURE-----
Server-side pre-receive hook
The pre-receive
hook is a lot less straight-forward. This hook receives input
via STDIN
. Each line consists of three items, separated by a single space:
[previous commit's sha1] [new commit's sha1] [refspec]
Typically, only the new sha1 is of much use to us. Internally, git is actually
keeping track of the new commit, even though it has not technically been
accepted into the repository. This allows us to use tools such as git show
to
get information on the commit and act on that information.
What I needed to do was inspect the commit message for a GPG-signed message; if none was found, reject the commit outright, but if one was present, validate it against my keyring, and abort if the signed message is invalid.
I originally started by using git show --pretty="format:%b" [sha1]
However, I
discovered that git does something… odd… to commit messages. The first 50
characters or so are considered the commit's "subject" — and any newlines found
in the subject are silently stripped. This meant that I was getting, for my
purposes, a truncated message that would never validate (as the GPG signature
header was getting stripped); even including the subject in the format did not
work, since the newlines within it were missing. The only way I found to get
the full commit message was to use git show --pretty=raw [sha1]
. This,
however, gives me also the commit headers as well as the diff — which means I
have to parse the response.
What follows is a PHP implementation I did that does exactly that: grabs the full message and redirects it to a temporary file, parses that file for the commit message, and then acts on it.
###!/usr/bin/php
<?php
echo "Checking for GPG signature... ";
$fh = fopen('php://stdin', 'r');
$tmpdir = sys_get_temp_dir();
while (!feof($fh)) {
$line = fgets($fh);
list($old, $new, $ref) = explode(' ', $line);
// Create a tmp file with the commit log
$logTmp = tempnam($tmpdir, 'LOG_');
$body = shell_exec('git show --pretty=raw ' . $new . ' > ' . $logTmp);
$msgTmp = tempnam($tmpdir, 'MESSAGE_');
// Scan the commit log for a commit message
$log = fopen($logTmp, 'r');
$msg = fopen($msgTmp, 'a');
$signatureDetected = false;
while (!feof($log)) {
$line = fgets($log);
if (preg_match('/^(commit(ter)?|tree|parent|author)\s/', $line)) {
// Skip the commit log headers
continue;
}
if (preg_match('/^diff\s/', $line)) {
// Stop scanning when we reach the diff
break;
}
if (preg_match('/^\s+-+BEGIN [A-Z]+ SIGNED MESSAGE/', $line)) {
// We have a signed message, so start appending it
// to a separate tmp file
$signatureDetected = true;
$line = preg_replace('/^\s+/', '', $line);
fwrite($msg, $line);
continue;
}
if ($signatureDetected) {
// If we have detected a signed message, continue appending lines to
// it. Commit message lines are indented, so strip indentation.
$line = preg_replace('/^\s+/', '', $line);
if ('' === $line) {
$line = "\n";"
}
fwrite($msg, $line);
}
}
fclose($log);
fclose($msg);
if (!signatureDetected) {
// No signed message detected; report and abort
unlink($logTmp);
unlink($msgTmp);
echo "no GPG signature detected; commit aborted\n";
exit(1);
}
$verification = shell_exec('gpg --verify ' . $msgTmp . ' 2>&1');
if (!preg_match('/Good signature/s', $verification)) {
// Failed to verify signed message; report and abort
unlink($logTmp);
unlink($msgTmp);
echo "invalid GPG signature; commit aborted\n";
exit(1);
}
unlink($logTmp);
unlink($msgTmp);
}
echo "verified!\n";
exit(0);
There are likely more elegant ways to accomplish this, including solutions in other languages. However, it works quite well.
Conclusions
Git hooks are quite powerful, and delving into them has given me confidence that I can create some nice automation for the ZF git repository when we are ready to open it to the public.
That said, I don't know if we'll actually use commit signing such as this, as it has a few drawbacks:
- The commit signing is not really cross-platform. This can likely be remedied, but it would require that people on different operating systems and using different tools (such as EGit, TortoiseGit, etc) develop and provide signing mechanisms for the client-side.
- It introduces complexity for those developing patches. If developers begin
without having the
commit-msg
hook in place, they then have to create a new branch and a squashed commit afterwards in order to ensure the final patches can go into the canonical repository. - The two reasons above kind of defeat the purpose of moving to a Distributed VCS in the first place — which is to simplify development and make it more democratic.
Regardless of whether or not we decide to use this technique, when researching the issue, I saw plenty of posts from people wanting to implement commit signing, but not sure how to accomplish it. Perhaps this post will serve as a starting point for many.