git: Allow signing of commits

Alexandru Băluț created

We could add more ways of specifying a key, such as picking one from the
adopted identity, but for now with this change we allow users who know what
they are doing to sign their bug changes.

By simply passing "-S" to `git commit-tree` when "commit.gpgsign" is "true", we
use git's mechanism for key choosing which is either by the configured user
name and email OR by whatever is specified in "user.signingkey".

Change summary

repository/git.go         | 33 ++++++++++---
repository/git_test.go    | 32 +++++++++++++
repository/git_testing.go | 94 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 150 insertions(+), 9 deletions(-)

Detailed changes

repository/git.go 🔗

@@ -270,19 +270,34 @@ func (repo *GitRepo) StoreTree(entries []TreeEntry) (git.Hash, error) {
 
 // StoreCommit will store a Git commit with the given Git tree
 func (repo *GitRepo) StoreCommit(treeHash git.Hash) (git.Hash, error) {
-	stdout, err := repo.runGitCommand("commit-tree", string(treeHash))
-
-	if err != nil {
-		return "", err
-	}
-
-	return git.Hash(stdout), nil
+	return repo.storeCommitRaw(treeHash)
 }
 
 // StoreCommitWithParent will store a Git commit with the given Git tree
 func (repo *GitRepo) StoreCommitWithParent(treeHash git.Hash, parent git.Hash) (git.Hash, error) {
-	stdout, err := repo.runGitCommand("commit-tree", string(treeHash),
-		"-p", string(parent))
+	return repo.storeCommitRaw(treeHash, "-p", string(parent))
+}
+
+func (repo *GitRepo) storeCommitRaw(treeHash git.Hash, extraArgs ...string) (git.Hash, error) {
+	args := []string{"commit-tree"}
+
+	// `git commit-tree` uses user.signingkey and gpg.program, but not commit.gpgsign.
+	// We read commit.gpgsign ourselves and simply pass -S to `git commit-tree`.
+	config := repo.LocalConfig()
+	gpgsign, err := config.ReadBool("commit.gpgsign")
+	if err != nil && err != ErrNoConfigEntry {
+		// There are more than one entries, or some other error.
+		return "", errors.Wrap(err, "failed to read local commit.gpgsign")
+	}
+	if gpgsign {
+		args = append(args, "-S")
+	}
+
+	args = append(args, extraArgs...)
+
+	args = append(args, string(treeHash))
+
+	stdout, err := repo.runGitCommand(args...)
 
 	if err != nil {
 		return "", err

repository/git_test.go 🔗

@@ -2,6 +2,7 @@
 package repository
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -65,3 +66,34 @@ func TestConfig(t *testing.T) {
 	err = repo.LocalConfig().RemoveAll("section.key")
 	assert.Error(t, err)
 }
+
+// checkStoreCommit creates a commit and checks if it has been signed.
+// See https://git-scm.com/docs/git-log#Documentation/git-log.txt-emGem
+// for possible signature status values.
+func checkStoreCommit(t *testing.T, repo *GitRepo, expectedSignedStatus string) {
+	content := fmt.Sprintf("file content %d", repo.CreateTime())
+	blobHash, err := repo.StoreData([]byte(content))
+	assert.NoError(t, err)
+
+	var entries = []TreeEntry{{Blob, blobHash, "filename"}}
+	treeHash, err := repo.StoreTree(entries)
+	assert.NoError(t, err)
+	commitHash, err := repo.StoreCommit(treeHash)
+	assert.NoError(t, err)
+
+	signedStatus, err := repo.runGitCommand("log", "--pretty=%G?", commitHash.String())
+	assert.NoError(t, err)
+	assert.Equal(t, expectedSignedStatus, signedStatus)
+}
+
+func TestGitRepo_StoreCommit(t *testing.T) {
+	repo := CreateTestRepo(false)
+	defer CleanupTestRepos(t, repo)
+
+	// Commit and expect no signature.
+	checkStoreCommit(t,repo, "N")
+
+	// Commit and expect a good signature with unknown validity.
+	setupSigningKey(t, repo)
+	checkStoreCommit(t, repo, "U")
+}

repository/git_testing.go 🔗

@@ -1,11 +1,17 @@
 package repository
 
 import (
+	"fmt"
 	"io/ioutil"
 	"log"
 	"os"
+	"os/exec"
 	"strings"
 	"testing"
+
+	"github.com/stretchr/testify/require"
+	"golang.org/x/crypto/openpgp"
+	"golang.org/x/crypto/openpgp/armor"
 )
 
 // This is intended for testing only
@@ -42,6 +48,94 @@ func CreateTestRepo(bare bool) *GitRepo {
 	return repo
 }
 
+// setupSigningKey creates a GPG key and sets up the local config so it's used.
+// The key id is set as "user.signingkey". For the key to be found, a `gpg`
+// wrapper which uses only a custom keyring is created and set as "gpg.program".
+// Finally "commit.gpgsign" is set to true so the signing takes place.
+func setupSigningKey(t *testing.T, repo *GitRepo) {
+	config := repo.LocalConfig()
+
+	// Generate a key pair for signing commits.
+	entity, err := openpgp.NewEntity("First Last", "", "fl@example.org", nil)
+	require.NoError(t, err)
+
+	err = config.StoreString("user.signingkey", entity.PrivateKey.KeyIdString())
+	require.NoError(t, err)
+
+	// Armor the private part.
+	privBuilder := &strings.Builder{}
+	w, err := armor.Encode(privBuilder, openpgp.PrivateKeyType, nil)
+	require.NoError(t, err)
+	err = entity.SerializePrivate(w, nil)
+	require.NoError(t, err)
+	err = w.Close()
+	require.NoError(t, err)
+	armoredPriv := privBuilder.String()
+
+	// Armor the public part.
+	pubBuilder := &strings.Builder{}
+	w, err = armor.Encode(pubBuilder, openpgp.PublicKeyType, nil)
+	require.NoError(t, err)
+	err = entity.Serialize(w)
+	require.NoError(t, err)
+	err = w.Close()
+	require.NoError(t, err)
+	armoredPub := pubBuilder.String()
+
+	// Create a custom gpg keyring to be used when creating commits with `git`.
+	keyring, err := ioutil.TempFile("", "keyring")
+	require.NoError(t, err)
+
+	// Import the armored private key to the custom keyring.
+	priv, err := ioutil.TempFile("", "privkey")
+	require.NoError(t, err)
+	_, err = fmt.Fprint(priv, armoredPriv)
+	require.NoError(t, err)
+	err = priv.Close()
+	require.NoError(t, err)
+	err = exec.Command("gpg", "--no-default-keyring", "--keyring", keyring.Name(), "--import", priv.Name()).Run()
+	require.NoError(t, err)
+
+	// Import the armored public key to the custom keyring.
+	pub, err := ioutil.TempFile("", "pubkey")
+	require.NoError(t, err)
+	_, err = fmt.Fprint(pub, armoredPub)
+	require.NoError(t, err)
+	err = pub.Close()
+	require.NoError(t, err)
+	err = exec.Command("gpg", "--no-default-keyring", "--keyring", keyring.Name(), "--import", pub.Name()).Run()
+	require.NoError(t, err)
+
+	// Use a gpg wrapper to use a custom keyring containing GPGKeyID.
+	gpgWrapper := createGPGWrapper(t, keyring.Name())
+	if err := config.StoreString("gpg.program", gpgWrapper); err != nil {
+		log.Fatal("failed to set gpg.program for test repository: ", err)
+	}
+
+	if err := config.StoreString("commit.gpgsign", "true"); err != nil {
+		log.Fatal("failed to set commit.gpgsign for test repository: ", err)
+	}
+}
+
+// createGPGWrapper creates a shell script running gpg with a specific keyring.
+func createGPGWrapper(t *testing.T, keyringPath string) string {
+	file, err := ioutil.TempFile("", "gpgwrapper")
+	require.NoError(t, err)
+
+	_, err = fmt.Fprintf(file, `#!/bin/sh
+exec gpg --no-default-keyring --keyring="%s" "$@"
+`, keyringPath)
+	require.NoError(t, err)
+
+	err = file.Close()
+	require.NoError(t, err)
+
+	err = os.Chmod(file.Name(), os.FileMode(0700))
+	require.NoError(t, err)
+
+	return file.Name()
+}
+
 func CleanupTestRepos(t testing.TB, repos ...Repo) {
 	var firstErr error
 	for _, repo := range repos {