From dad2d5257131e3cb0b28c6cb9932a8199047a371 Mon Sep 17 00:00:00 2001 From: Owen Voke Date: Fri, 11 Jun 2021 15:55:17 +0100 Subject: [PATCH] feat: add commands for managing GPG keys --- pkg/cmd/gpg-key/add/add.go | 100 ++++++++++++++++++++++++++++++++ pkg/cmd/gpg-key/add/http.go | 67 ++++++++++++++++++++++ pkg/cmd/gpg-key/gpg-key.go | 21 +++++++ pkg/cmd/gpg-key/list/http.go | 58 +++++++++++++++++++ pkg/cmd/gpg-key/list/list.go | 108 +++++++++++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 6 files changed, 356 insertions(+) create mode 100644 pkg/cmd/gpg-key/add/add.go create mode 100644 pkg/cmd/gpg-key/add/http.go create mode 100644 pkg/cmd/gpg-key/gpg-key.go create mode 100644 pkg/cmd/gpg-key/list/http.go create mode 100644 pkg/cmd/gpg-key/list/list.go diff --git a/pkg/cmd/gpg-key/add/add.go b/pkg/cmd/gpg-key/add/add.go new file mode 100644 index 000000000..d022f292a --- /dev/null +++ b/pkg/cmd/gpg-key/add/add.go @@ -0,0 +1,100 @@ +package add + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type AddOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HTTPClient func() (*http.Client, error) + + KeyFile string +} + +func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { + opts := &AddOptions{ + HTTPClient: f.HttpClient, + Config: f.Config, + IO: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "add []", + Short: "Add a GPG key to your GitHub account", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + if opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("GPG public key file missing")} + } + opts.KeyFile = "-" + } else { + opts.KeyFile = args[0] + } + + if runF != nil { + return runF(opts) + } + return runAdd(opts) + }, + } + + return cmd +} + +func runAdd(opts *AddOptions) error { + httpClient, err := opts.HTTPClient() + if err != nil { + return err + } + + var keyReader io.Reader + if opts.KeyFile == "-" { + keyReader = opts.IO.In + defer opts.IO.In.Close() + } else { + f, err := os.Open(opts.KeyFile) + if err != nil { + return err + } + defer f.Close() + keyReader = f + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + hostname, err := cfg.DefaultHost() + if err != nil { + return err + } + + err = GPGKeyUpload(httpClient, hostname, keyReader) + if err != nil { + if errors.Is(err, scopesError) { + cs := opts.IO.ColorScheme() + fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list GPG keys\n") + fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s write:gpg_key")) + return cmdutil.SilentError + } + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s GPG public key added to your account\n", cs.SuccessIcon()) + } + return nil +} diff --git a/pkg/cmd/gpg-key/add/http.go b/pkg/cmd/gpg-key/add/http.go new file mode 100644 index 000000000..37e117aa1 --- /dev/null +++ b/pkg/cmd/gpg-key/add/http.go @@ -0,0 +1,67 @@ +package add + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/ioutil" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" +) + +var scopesError = errors.New("insufficient OAuth scopes") + +func GPGKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader) error { + url := ghinstance.RESTPrefix(hostname) + "user/gpg_keys" + + keyBytes, err := ioutil.ReadAll(keyFile) + if err != nil { + return err + } + + payload := map[string]string{ + "armored_public_key": string(keyBytes), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return scopesError + } else if resp.StatusCode > 299 { + var httpError api.HTTPError + err := api.HandleHTTPError(resp) + if errors.As(err, &httpError) && isDuplicateError(&httpError) { + return nil + } + return err + } + + _, err = io.Copy(ioutil.Discard, resp.Body) + if err != nil { + return err + } + + return nil +} + +func isDuplicateError(err *api.HTTPError) bool { + return err.StatusCode == 422 && len(err.Errors) == 1 && + err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use" +} diff --git a/pkg/cmd/gpg-key/gpg-key.go b/pkg/cmd/gpg-key/gpg-key.go new file mode 100644 index 000000000..13cd5a1ec --- /dev/null +++ b/pkg/cmd/gpg-key/gpg-key.go @@ -0,0 +1,21 @@ +package key + +import ( + cmdAdd "github.com/cli/cli/pkg/cmd/gpg-key/add" + cmdList "github.com/cli/cli/pkg/cmd/gpg-key/list" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdGPGKey(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "gpg-key ", + Short: "Manage GPG keys", + Long: "Manage GPG keys registered with your GitHub account", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil)) + + return cmd +} diff --git a/pkg/cmd/gpg-key/list/http.go b/pkg/cmd/gpg-key/list/http.go new file mode 100644 index 000000000..6610c85bf --- /dev/null +++ b/pkg/cmd/gpg-key/list/http.go @@ -0,0 +1,58 @@ +package list + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" +) + +var scopesError = errors.New("insufficient OAuth scopes") + +type gpgKey struct { + KeyId string `json:"key_id"` + PublicKey string `json:"public_key"` + ExpiresAt time.Time `json:"expires_at"` +} + +func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error) { + resource := "user/gpg_keys" + if userHandle != "" { + resource = fmt.Sprintf("users/%s/gpg_keys", userHandle) + } + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, scopesError + } else if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keys []gpgKey + err = json.Unmarshal(b, &keys) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/cmd/gpg-key/list/list.go b/pkg/cmd/gpg-key/list/list.go new file mode 100644 index 000000000..58517a622 --- /dev/null +++ b/pkg/cmd/gpg-key/list/list.go @@ -0,0 +1,108 @@ +package list + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HTTPClient func() (*http.Client, error) +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + HTTPClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "Lists GPG keys in your GitHub account", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + apiClient, err := opts.HTTPClient() + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + host, err := cfg.DefaultHost() + if err != nil { + return err + } + + gpgKeys, err := userKeys(apiClient, host, "") + if err != nil { + if errors.Is(err, scopesError) { + cs := opts.IO.ColorScheme() + fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list GPG keys\n") + fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:gpg_key")) + return cmdutil.SilentError + } + return err + } + + if len(gpgKeys) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "No GPG keys present in GitHub account.") + return cmdutil.SilentError + } + + t := utils.NewTablePrinter(opts.IO) + cs := opts.IO.ColorScheme() + + for _, gpgKey := range gpgKeys { + t.AddField(gpgKey.KeyId, nil, nil) + + expiresAt := gpgKey.ExpiresAt.Format(time.RFC3339) + if gpgKey.ExpiresAt.IsZero() { + expiresAt = "Never" + } + t.AddField(expiresAt, nil, cs.Gray) + + t.AddField(gpgKey.PublicKey, truncateMiddle, nil) + t.EndRow() + } + + return t.Render() +} + +func truncateMiddle(maxWidth int, t string) string { + if len(t) <= maxWidth { + return t + } + + ellipsis := "..." + if maxWidth < len(ellipsis)+2 { + return t[0:maxWidth] + } + + halfWidth := (maxWidth - len(ellipsis)) / 2 + remainder := (maxWidth - len(ellipsis)) % 2 + return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:] +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index cc971a0d4..a1a134c7d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -16,6 +16,7 @@ import ( extensionsCmd "github.com/cli/cli/pkg/cmd/extensions" "github.com/cli/cli/pkg/cmd/factory" gistCmd "github.com/cli/cli/pkg/cmd/gist" + gpgKeyCmd "github.com/cli/cli/pkg/cmd/gpg-key" issueCmd "github.com/cli/cli/pkg/cmd/issue" prCmd "github.com/cli/cli/pkg/cmd/pr" releaseCmd "github.com/cli/cli/pkg/cmd/release" @@ -80,6 +81,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(configCmd.NewCmdConfig(f)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) + cmd.AddCommand(gpgKeyCmd.NewCmdGPGKey(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) cmd.AddCommand(extensionsCmd.NewCmdExtensions(f.IOStreams)) cmd.AddCommand(secretCmd.NewCmdSecret(f))