Detailed changes
@@ -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)
+}
@@ -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",
+ )
+}
@@ -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)
+}
@@ -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
@@ -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
+}
@@ -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),
@@ -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 {