feat: add commands for managing GPG keys

This commit is contained in:
Owen Voke 2021-06-11 15:55:17 +01:00
parent 75abeb13a8
commit dad2d52571
No known key found for this signature in database
GPG key ID: B6A92A86D7A46D64
6 changed files with 356 additions and 0 deletions

100
pkg/cmd/gpg-key/add/add.go Normal file
View file

@ -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 [<key-file>]",
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
}

View file

@ -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"
}

View file

@ -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 <command>",
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
}

View file

@ -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
}

View file

@ -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:]
}

View file

@ -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))