Allow managing keys

Alexandru Băluț created

Change summary

commands/user_key.go      | 61 +++++++++++++++++++++++++++
commands/user_key_add.go  | 90 +++++++++++++++++++++++++++++++++++++++++
commands/user_key_rm.go   | 81 ++++++++++++++++++++++++++++++++++++
identity/identity_test.go | 66 ++++++++++++++++++++++-------
identity/key.go           | 77 ++++++++++++++++++++++++++++++++--
identity/version_test.go  |  6 -
input/input.go            | 50 +++++++++++++++++-----
7 files changed, 392 insertions(+), 39 deletions(-)

Detailed changes

commands/user_key.go 🔗

@@ -0,0 +1,61 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/interrupt"
+	"github.com/spf13/cobra"
+)
+
+func ResolveUser(repo *cache.RepoCache, args []string) (*cache.IdentityCache, []string, error) {
+	var err error
+	var id *cache.IdentityCache
+	if len(args) > 0 {
+		id, err = repo.ResolveIdentityPrefix(args[0])
+		args = args[1:]
+	} else {
+		id, err = repo.GetUserIdentity()
+	}
+	return id, args, err
+}
+
+func runKey(cmd *cobra.Command, args []string) error {
+	backend, err := cache.NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
+
+	id, args, err := ResolveUser(backend, args)
+	if err != nil {
+		return err
+	}
+
+	if len(args) > 0 {
+		return fmt.Errorf("unexpected arguments: %s", args)
+	}
+
+	for _, key := range id.Keys() {
+		pubkey, err := key.GetPublicKey()
+		if err != nil {
+			return err
+		}
+		fmt.Println(identity.EncodeKeyFingerprint(pubkey.Fingerprint))
+	}
+
+	return nil
+}
+
+var keyCmd = &cobra.Command{
+	Use:     "key [<user-id>]",
+	Short:   "Display, add or remove keys to/from a user.",
+	PreRunE: loadRepoEnsureUser,
+	RunE:    runKey,
+}
+
+func init() {
+	userCmd.AddCommand(keyCmd)
+}

commands/user_key_add.go 🔗

@@ -0,0 +1,90 @@
+package commands
+
+import (
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/input"
+	"github.com/MichaelMure/git-bug/util/interrupt"
+	"github.com/spf13/cobra"
+)
+
+var (
+	keyAddArmoredFile    string
+	keyAddArmored        string
+)
+
+func runKeyAdd(cmd *cobra.Command, args []string) error {
+	backend, err := cache.NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
+
+	id, args, err := ResolveUser(backend, args)
+	if err != nil {
+		return err
+	}
+
+	if len(args) > 0 {
+		return fmt.Errorf("unexpected arguments: %s", args)
+	}
+
+	if keyAddArmoredFile != "" && keyAddArmored == "" {
+		keyAddArmored, err = input.TextFileInput(keyAddArmoredFile)
+		if err != nil {
+			return err
+		}
+	}
+
+	if keyAddArmoredFile == "" && keyAddArmored == "" {
+		keyAddArmored, err = input.IdentityVersionKeyEditorInput(backend, "")
+		if err == input.ErrEmptyMessage {
+			fmt.Println("Empty PGP key, aborting.")
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+	}
+
+	key, err := identity.NewKey(keyAddArmored)
+
+	if err != nil {
+		return err
+	}
+
+	err = id.Mutate(func(mutator identity.Mutator) identity.Mutator {
+		mutator.Keys = append(mutator.Keys, key)
+		return mutator
+	})
+
+	if err != nil {
+		return err
+	}
+
+	return id.Commit()
+}
+
+var keyAddCmd = &cobra.Command{
+	Use:     "add [<user-id>]",
+	Short:   "Add a PGP key from a user.",
+	PreRunE: loadRepoEnsureUser,
+	RunE:    runKeyAdd,
+}
+
+func init() {
+	keyCmd.AddCommand(keyAddCmd)
+
+	keyAddCmd.Flags().SortFlags = false
+
+	keyAddCmd.Flags().StringVarP(&keyAddArmoredFile, "file", "F", "",
+		"Take the armored PGP public key from the given file. Use - to read the message from the standard input",
+	)
+
+	keyAddCmd.Flags().StringVarP(&keyAddArmored, "key", "k", "",
+		"Provide the armored PGP public key from the command line",
+	)
+}

commands/user_key_rm.go 🔗

@@ -0,0 +1,81 @@
+package commands
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/util/interrupt"
+	"github.com/spf13/cobra"
+)
+
+func runKeyRm(cmd *cobra.Command, args []string) error {
+	backend, err := cache.NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
+
+	if len(args) == 0 {
+		return errors.New("missing key ID")
+	}
+
+	keyFingerprint := args[0]
+	args = args[1:]
+
+	id, args, err := ResolveUser(backend, args)
+	if err != nil {
+		return err
+	}
+
+	if len(args) > 0 {
+		return fmt.Errorf("unexpected arguments: %s", args)
+	}
+
+	fingerprint, err := identity.DecodeKeyFingerprint(keyFingerprint)
+	if err != nil {
+		return err
+	}
+
+	var removedKey *identity.Key
+	err = id.Mutate(func(mutator identity.Mutator) identity.Mutator {
+		for j, key := range mutator.Keys {
+			pubkey, err := key.GetPublicKey()
+			if err != nil {
+				fmt.Printf("Warning: failed to decode public key: %s", err)
+				continue
+			}
+
+			if pubkey.Fingerprint == fingerprint {
+				removedKey = key
+				copy(mutator.Keys[j:], mutator.Keys[j+1:])
+				mutator.Keys = mutator.Keys[:len(mutator.Keys)-1]
+				break
+			}
+		}
+		return mutator
+	})
+
+	if err != nil {
+		return err
+	}
+
+	if removedKey == nil {
+		return errors.New("key not found")
+	}
+
+	return id.Commit()
+}
+
+var keyRmCmd = &cobra.Command{
+	Use:     "rm <key-fingerprint> [<user-id>]",
+	Short:   "Remove a PGP key from the adopted or the specified user.",
+	PreRunE: loadRepoEnsureUser,
+	RunE:    runKeyRm,
+}
+
+func init() {
+	keyCmd.AddCommand(keyRmCmd)
+}

identity/identity_test.go 🔗

@@ -2,13 +2,35 @@ package identity
 
 import (
 	"encoding/json"
+	"strings"
 	"testing"
 
 	"github.com/MichaelMure/git-bug/entity"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/crypto/openpgp"
+	"golang.org/x/crypto/openpgp/armor"
 )
 
+// createPubkey returns an armored public PGP key.
+func createPubkey(t *testing.T) string {
+	// Generate a key pair for signing commits.
+	pgpEntity, err := openpgp.NewEntity("First Last", "", "fl@example.org", nil)
+	require.NoError(t, err)
+
+	// Armor the public part.
+	pubBuilder := &strings.Builder{}
+	w, err := armor.Encode(pubBuilder, openpgp.PublicKeyType, nil)
+	require.NoError(t, err)
+	err = pgpEntity.Serialize(w)
+	require.NoError(t, err)
+	err = w.Close()
+	require.NoError(t, err)
+	armoredPub := pubBuilder.String()
+	return armoredPub
+}
+
 // Test the commit and load of an Identity with multiple versions
 func TestIdentityCommitLoad(t *testing.T) {
 	mockRepo := repository.NewMockRepoForTest()
@@ -45,7 +67,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyA"},
+					{ArmoredPublicKey: createPubkey(t)},
 				},
 			},
 			{
@@ -53,7 +75,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyB"},
+					{ArmoredPublicKey: createPubkey(t)},
 				},
 			},
 			{
@@ -61,7 +83,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyC"},
+					{ArmoredPublicKey: createPubkey(t)},
 				},
 			},
 		},
@@ -75,6 +97,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 	loaded, err = ReadLocal(mockRepo, identity.id)
 	assert.Nil(t, err)
 	commitsAreSet(t, loaded)
+	loadKeys(loaded)
 	assert.Equal(t, identity, loaded)
 
 	// add more version
@@ -89,7 +112,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 		name:  "René Descartes",
 		email: "rene.descartes@example.com",
 		keys: []*Key{
-			{PubKey: "pubkeyD"},
+			{ArmoredPublicKey: createPubkey(t)},
 		},
 	})
 
@@ -98,7 +121,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 		name:  "René Descartes",
 		email: "rene.descartes@example.com",
 		keys: []*Key{
-			{PubKey: "pubkeyE"},
+			{ArmoredPublicKey: createPubkey(t)},
 		},
 	})
 
@@ -110,9 +133,18 @@ func TestIdentityCommitLoad(t *testing.T) {
 	loaded, err = ReadLocal(mockRepo, identity.id)
 	assert.Nil(t, err)
 	commitsAreSet(t, loaded)
+	loadKeys(loaded)
 	assert.Equal(t, identity, loaded)
 }
 
+func loadKeys(identity *Identity) {
+	for _, v := range identity.versions {
+		for _, k := range v.keys {
+			k.GetPublicKey()
+		}
+	}
+}
+
 func commitsAreSet(t *testing.T, identity *Identity) {
 	for _, version := range identity.versions {
 		assert.NotEmpty(t, version.commitHash)
@@ -129,7 +161,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyA"},
+					{ArmoredPublicKey: "pubkeyA"},
 				},
 			},
 			{
@@ -137,7 +169,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyB"},
+					{ArmoredPublicKey: "pubkeyB"},
 				},
 			},
 			{
@@ -145,7 +177,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyC"},
+					{ArmoredPublicKey: "pubkeyC"},
 				},
 			},
 			{
@@ -153,7 +185,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyD"},
+					{ArmoredPublicKey: "pubkeyD"},
 				},
 			},
 			{
@@ -161,20 +193,20 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				name:  "René Descartes",
 				email: "rene.descartes@example.com",
 				keys: []*Key{
-					{PubKey: "pubkeyE"},
+					{ArmoredPublicKey: "pubkeyE"},
 				},
 			},
 		},
 	}
 
 	assert.Nil(t, identity.ValidKeysAtTime(10))
-	assert.Equal(t, identity.ValidKeysAtTime(100), []*Key{{PubKey: "pubkeyA"}})
-	assert.Equal(t, identity.ValidKeysAtTime(140), []*Key{{PubKey: "pubkeyA"}})
-	assert.Equal(t, identity.ValidKeysAtTime(200), []*Key{{PubKey: "pubkeyB"}})
-	assert.Equal(t, identity.ValidKeysAtTime(201), []*Key{{PubKey: "pubkeyD"}})
-	assert.Equal(t, identity.ValidKeysAtTime(202), []*Key{{PubKey: "pubkeyD"}})
-	assert.Equal(t, identity.ValidKeysAtTime(300), []*Key{{PubKey: "pubkeyE"}})
-	assert.Equal(t, identity.ValidKeysAtTime(3000), []*Key{{PubKey: "pubkeyE"}})
+	assert.Equal(t, identity.ValidKeysAtTime(100), []*Key{{ArmoredPublicKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(140), []*Key{{ArmoredPublicKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(200), []*Key{{ArmoredPublicKey: "pubkeyB"}})
+	assert.Equal(t, identity.ValidKeysAtTime(201), []*Key{{ArmoredPublicKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(202), []*Key{{ArmoredPublicKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(300), []*Key{{ArmoredPublicKey: "pubkeyE"}})
+	assert.Equal(t, identity.ValidKeysAtTime(3000), []*Key{{ArmoredPublicKey: "pubkeyE"}})
 }
 
 // Test the immutable or mutable metadata search

identity/key.go 🔗

@@ -1,18 +1,83 @@
 package identity
 
+import (
+	"encoding/hex"
+	"fmt"
+	"strings"
+
+	"github.com/pkg/errors"
+	"golang.org/x/crypto/openpgp/armor"
+	"golang.org/x/crypto/openpgp/packet"
+)
+
 type Key struct {
-	// The GPG fingerprint of the key
-	Fingerprint string `json:"fingerprint"`
-	PubKey      string `json:"pub_key"`
+	// PubKey is the armored PGP public key.
+	ArmoredPublicKey string `json:"pub_key"`
+
+	publicKey *packet.PublicKey `json:"-"`
 }
 
-func (k *Key) Validate() error {
-	// Todo
+func NewKey(armoredPGPKey string) (*Key, error) {
+	publicKey, err := parsePublicKey(armoredPGPKey)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Key{armoredPGPKey, publicKey}, nil
+}
+
+func parsePublicKey(armoredPublicKey string) (*packet.PublicKey, error) {
+	block, err := armor.Decode(strings.NewReader(armoredPublicKey))
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to dearmor public key")
+	}
+
+	reader := packet.NewReader(block.Body)
+	p, err := reader.Next()
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to read public key packet")
+	}
+
+	publicKey, ok := p.(*packet.PublicKey)
+	if !ok {
+		return nil, errors.New("got no packet.PublicKey")
+	}
 
-	return nil
+	return publicKey, nil
+}
+
+// DecodeKeyFingerprint decodes a 40 hex digits long fingerprint into bytes.
+func DecodeKeyFingerprint(keyFingerprint string) ([20]byte, error) {
+	var fingerprint [20]byte
+	fingerprintBytes, err := hex.DecodeString(keyFingerprint)
+	if err != nil {
+		return fingerprint, err
+	}
+	if len(fingerprintBytes) != 20 {
+		return fingerprint, fmt.Errorf("expected 20 bytes not %d", len(fingerprintBytes))
+	}
+	copy(fingerprint[:], fingerprintBytes)
+	return fingerprint, nil
+}
+
+func EncodeKeyFingerprint(fingerprint [20]byte) string {
+	return hex.EncodeToString(fingerprint[:])
+}
+
+func (k *Key) Validate() error {
+	_, err := k.GetPublicKey()
+	return err
 }
 
 func (k *Key) Clone() *Key {
 	clone := *k
 	return &clone
 }
+
+func (k *Key) GetPublicKey() (*packet.PublicKey, error) {
+	var err error
+	if k.publicKey == nil {
+		k.publicKey, err = parsePublicKey(k.ArmoredPublicKey)
+	}
+	return k.publicKey, err
+}

identity/version_test.go 🔗

@@ -14,12 +14,10 @@ func TestVersionSerialize(t *testing.T) {
 		avatarURL: "avatarUrl",
 		keys: []*Key{
 			{
-				Fingerprint: "fingerprint1",
-				PubKey:      "pubkey1",
+				ArmoredPublicKey:      "pubkey1",
 			},
 			{
-				Fingerprint: "fingerprint2",
-				PubKey:      "pubkey2",
+				ArmoredPublicKey:      "pubkey2",
 			},
 		},
 		nonce: makeNonce(20),

input/input.go 🔗

@@ -18,6 +18,7 @@ import (
 )
 
 const messageFilename = "BUG_MESSAGE_EDITMSG"
+const keyFilename = "KEY_EDITMSG"
 
 // ErrEmptyMessage is returned when the required message has not been entered
 var ErrEmptyMessage = errors.New("empty message")
@@ -54,7 +55,7 @@ func BugCreateEditorInput(repo repository.RepoCommon, preTitle string, preMessag
 // BugCreateFileInput read from either from a file or from the standard input
 // and extract a title and a message
 func BugCreateFileInput(fileName string) (string, string, error) {
-	raw, err := fromFile(fileName)
+	raw, err := TextFileInput(fileName)
 	if err != nil {
 		return "", "", err
 	}
@@ -112,10 +113,29 @@ func BugCommentEditorInput(repo repository.RepoCommon, preMessage string) (strin
 	return processComment(raw)
 }
 
-// BugCommentFileInput read from either from a file or from the standard input
+const identityVersionKeyTemplate = `%s
+
+# Please enter the armored key block. Lines starting with '#' will be ignored,
+# and an empty message aborts the operation.
+`
+// IdentityVersionKeyEditorInput will open the default editor in the terminal
+// with a template for the user to fill. The file is then processed to extract
+// the key.
+func IdentityVersionKeyEditorInput(repo repository.RepoCommon, preMessage string) (string, error) {
+	template := fmt.Sprintf(identityVersionKeyTemplate, preMessage)
+	raw, err := launchEditorWithTemplate(repo, keyFilename, template)
+
+	if err != nil {
+		return "", err
+	}
+
+	return removeCommentedLines(raw), nil
+}
+
+// BugCommentFileInput read either from a file or from the standard input
 // and extract a message
 func BugCommentFileInput(fileName string) (string, error) {
-	raw, err := fromFile(fileName)
+	raw, err := TextFileInput(fileName)
 	if err != nil {
 		return "", err
 	}
@@ -124,6 +144,18 @@ func BugCommentFileInput(fileName string) (string, error) {
 }
 
 func processComment(raw string) (string, error) {
+	message := removeCommentedLines(raw)
+
+	if message == "" {
+		return "", ErrEmptyMessage
+	}
+
+	return message, nil
+}
+
+// removeCommentedLines removes the lines starting with '#' and and
+// trims the result.
+func removeCommentedLines(raw string) string {
 	lines := strings.Split(raw, "\n")
 
 	var buffer bytes.Buffer
@@ -135,13 +167,7 @@ func processComment(raw string) (string, error) {
 		buffer.WriteString("\n")
 	}
 
-	message := strings.TrimSpace(buffer.String())
-
-	if message == "" {
-		return "", ErrEmptyMessage
-	}
-
-	return message, nil
+	return strings.TrimSpace(buffer.String())
 }
 
 const bugTitleTemplate = `%s
@@ -297,11 +323,11 @@ func launchEditor(repo repository.RepoCommon, fileName string) (string, error) {
 	return string(output), err
 }
 
-// fromFile loads and returns the contents of a given file. If - is passed
+// TextFileInput loads and returns the contents of a given file. If - is passed
 // through, much like git, it will read from stdin. This can be piped data,
 // unless there is a tty in which case the user will be prompted to enter a
 // message.
-func fromFile(fileName string) (string, error) {
+func TextFileInput(fileName string) (string, error) {
 	if fileName == "-" {
 		stat, err := os.Stdin.Stat()
 		if err != nil {