diff --git a/commands/user_key.go b/commands/user_key.go new file mode 100644 index 0000000000000000000000000000000000000000..0cc8681fbcc8f84a15e5f2a6fb06d36832e1cb71 --- /dev/null +++ b/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 []", + Short: "Display, add or remove keys to/from a user.", + PreRunE: loadRepoEnsureUser, + RunE: runKey, +} + +func init() { + userCmd.AddCommand(keyCmd) +} diff --git a/commands/user_key_add.go b/commands/user_key_add.go new file mode 100644 index 0000000000000000000000000000000000000000..0102322d16efe40e7a851d70686d56b789ac0ac6 --- /dev/null +++ b/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 []", + 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", + ) +} diff --git a/commands/user_key_rm.go b/commands/user_key_rm.go new file mode 100644 index 0000000000000000000000000000000000000000..c6e62f7314524c05bc06c68e2198fcb58b8f6b91 --- /dev/null +++ b/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 []", + Short: "Remove a PGP key from the adopted or the specified user.", + PreRunE: loadRepoEnsureUser, + RunE: runKeyRm, +} + +func init() { + keyCmd.AddCommand(keyRmCmd) +} diff --git a/identity/identity_test.go b/identity/identity_test.go index ee6ccdf7dfd2140bbedb31a276137f51630a970a..791c9b419b7492e7261a003871e2669d2fae0814 100644 --- a/identity/identity_test.go +++ b/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 diff --git a/identity/key.go b/identity/key.go index cc948394aeddc438e1eb7961036b53259459fd92..7431086a8d1173a69b6b6ce9b76c07aa608965f8 100644 --- a/identity/key.go +++ b/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 +} diff --git a/identity/version_test.go b/identity/version_test.go index 25848eb5e6ede496e4f5ed942596314854e45334..dcfe73afc93f5371d8c83635353ffbec57bb2967 100644 --- a/identity/version_test.go +++ b/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), diff --git a/input/input.go b/input/input.go index ca787ceb516654fa4a2560b7051ad9246de44c62..79d8a6a3f7ce594464a9ae0ff29db51664f12ed8 100644 --- a/input/input.go +++ b/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 {