diff --git a/pkg/cmd/ssh-key/add/add.go b/pkg/cmd/ssh-key/add/add.go index 84d39d1e7..553c35985 100644 --- a/pkg/cmd/ssh-key/add/add.go +++ b/pkg/cmd/ssh-key/add/add.go @@ -7,6 +7,7 @@ import ( "os" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -19,6 +20,7 @@ type AddOptions struct { KeyFile string Title string + Type string } func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { @@ -49,6 +51,8 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command }, } + typeEnums := []string{shared.AuthenticationKey, shared.SigningKey} + cmdutil.StringEnumFlag(cmd, &opts.Type, "type", "", shared.AuthenticationKey, typeEnums, "Type of the ssh key") cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Title for the new key") return cmd } @@ -79,7 +83,14 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() - uploaded, err := SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + var uploaded bool + + if opts.Type == shared.SigningKey { + uploaded, err = SSHSigningKeyUpload(httpClient, hostname, keyReader, opts.Title) + } else { + uploaded, err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + } + if err != nil { return err } diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index d49aaea90..43222a6c4 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -39,6 +39,25 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "", opts: AddOptions{KeyFile: "-"}, }, + { + name: "valid signing key format, not already in use", + stdin: "ssh-ed25519 asdf", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse("[]")) + reg.Register( + httpmock.REST("POST", "user/ssh_signing_keys"), + httpmock.RESTPayload(200, ``, func(payload map[string]interface{}) { + assert.Contains(t, payload, "key") + assert.Empty(t, payload["title"]) + })) + }, + wantStdout: "", + wantStderr: "✓ Public key added to your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, { name: "valid key format, already in use", stdin: "ssh-ed25519 asdf title", @@ -58,6 +77,25 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "", opts: AddOptions{KeyFile: "-"}, }, + { + name: "valid signing key format, already in use", + stdin: "ssh-ed25519 asdf title", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[ + { + "id": 1, + "key": "ssh-ed25519 asdf", + "title": "anything" + } + ]`)) + }, + wantStdout: "", + wantStderr: "✓ Public key already exists on your account\n", + wantErrMsg: "", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, { name: "invalid key format", stdin: "ssh-ed25519", @@ -66,6 +104,14 @@ func Test_runAdd(t *testing.T) { wantErrMsg: "provided key is not in a valid format", opts: AddOptions{KeyFile: "-"}, }, + { + name: "invalid signing key format", + stdin: "ssh-ed25519", + wantStdout: "", + wantStderr: "", + wantErrMsg: "provided key is not in a valid format", + opts: AddOptions{KeyFile: "-", Type: "signing"}, + }, } for _, tt := range tests { diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index e716425e0..83aa77bdc 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -46,30 +46,82 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t "key": fullUserKey, } - payloadBytes, err := json.Marshal(payload) - if err != nil { - return false, err - } + err = keyUpload(httpClient, url, payload) - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes)) - if err != nil { - return false, err - } - - resp, err := httpClient.Do(req) - if err != nil { - return false, err - } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return false, api.HandleHTTPError(resp) - } - - _, err = io.Copy(io.Discard, resp.Body) if err != nil { return false, err } return true, nil } + +// Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not. +func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { + url := ghinstance.RESTPrefix(hostname) + "user/ssh_signing_keys" + + keyBytes, err := io.ReadAll(keyFile) + if err != nil { + return false, err + } + + fullUserKey := string(keyBytes) + splitKey := strings.Fields(fullUserKey) + if len(splitKey) < 2 { + return false, errors.New("provided key is not in a valid format") + } + + keyToCompare := splitKey[0] + " " + splitKey[1] + + keys, err := shared.UserSigningKeys(httpClient, hostname, "") + if err != nil { + return false, err + } + + for _, k := range keys { + if k.Key == keyToCompare { + return false, nil + } + } + + payload := map[string]string{ + "title": title, + "key": fullUserKey, + } + + err = keyUpload(httpClient, url, payload) + + if err != nil { + return false, err + } + + return true, nil +} + +func keyUpload(httpClient *http.Client, url string, payload map[string]string) error { + 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 > 299 { + return api.HandleHTTPError(resp) + } + + _, err = io.Copy(io.Discard, resp.Body) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index 0c0cbcd0b..12ee6d635 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -1,10 +1,14 @@ package list import ( + "errors" + "fmt" + "io" "net/http" "strconv" "time" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" @@ -55,12 +59,22 @@ func listRun(opts *ListOptions) error { } host, _ := cfg.Authentication().DefaultHost() - - sshKeys, err := shared.UserKeys(apiClient, host, "") - if err != nil { - return err + sshAuthKeys, authKeyErr := shared.UserKeys(apiClient, host, "") + if authKeyErr != nil { + printError(opts.IO.ErrOut, authKeyErr) } + sshSigningKeys, signKeyErr := shared.UserSigningKeys(apiClient, host, "") + if signKeyErr != nil { + printError(opts.IO.ErrOut, signKeyErr) + } + + if authKeyErr != nil && signKeyErr != nil { + return cmdutil.SilentError + } + + sshKeys := append(sshAuthKeys, sshSigningKeys...) + if len(sshKeys) == 0 { return cmdutil.NewNoResultsError("no SSH keys present in the GitHub account") } @@ -74,6 +88,7 @@ func listRun(opts *ListOptions) error { t.AddField("TITLE", nil, nil) t.AddField("ID", nil, nil) t.AddField("KEY", nil, nil) + t.AddField("TYPE", nil, nil) t.AddField("ADDED", nil, nil) t.EndRow() } @@ -86,12 +101,14 @@ func listRun(opts *ListOptions) error { t.AddField(sshKey.Title, nil, nil) t.AddField(id, nil, nil) t.AddField(sshKey.Key, truncateMiddle, nil) + t.AddField(sshKey.Type, nil, nil) t.AddField(text.FuzzyAgoAbbr(now, sshKey.CreatedAt), nil, cs.Gray) } else { t.AddField(sshKey.Title, nil, nil) t.AddField(sshKey.Key, nil, nil) t.AddField(createdAt, nil, nil) t.AddField(id, nil, nil) + t.AddField(sshKey.Type, nil, nil) } t.EndRow() @@ -114,3 +131,13 @@ func truncateMiddle(maxWidth int, t string) string { remainder := (maxWidth - len(ellipsis)) % 2 return t[0:halfWidth+remainder] + ellipsis + t[len(t)-halfWidth:] } + +func printError(w io.Writer, err error) { + fmt.Fprintln(w, "warning: ", err) + var httpErr api.HTTPError + if errors.As(err, &httpErr) { + if msg := httpErr.ScopesSuggestion(); msg != "" { + fmt.Fprintln(w, msg) + } + } +} diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go index 91466ad8f..7334beccf 100644 --- a/pkg/cmd/ssh-key/list/list_test.go +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -22,7 +22,7 @@ func TestListRun(t *testing.T) { wantErr bool }{ { - name: "list tty", + name: "list authentication and signing keys; in tty", opts: ListOptions{ HTTPClient: func() (*http.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) @@ -44,19 +44,31 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 321, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac Signing", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) return &http.Client{Transport: reg}, nil }, }, isTTY: true, wantStdout: heredoc.Doc(` - TITLE ID KEY ADDED - Mac 1234 ssh-rsa AAAABbBB123 1d - hubot@Windows 5678 ssh-rsa EEEEEEEK247 1d + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 authentication 1d + hubot@Windows 5678 ssh-rsa EEEEEEEK247 authentication 1d + Mac Signing 321 ssh-rsa AAAABbBB123 signing 1d `), wantStderr: "", }, { - name: "list non-tty", + name: "list authentication and signing keys; in non-tty", opts: ListOptions{ HTTPClient: func() (*http.Client, error) { createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") @@ -78,16 +90,96 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 321, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac Signing", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) return &http.Client{Transport: reg}, nil }, }, isTTY: false, wantStdout: heredoc.Doc(` - Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 - hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 + Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 1234 authentication + hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 5678 authentication + Mac Signing ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 321 signing `), wantStderr: "", }, + { + name: "only authentication ssh keys are available", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: heredoc.Doc(` + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 authentication 1d + `), + wantStderr: heredoc.Doc(` + warning: HTTP 404 (https://api.github.com/user/ssh_signing_keys?per_page=100) + `), + wantErr: false, + isTTY: true, + }, + { + name: "only signing ssh keys are available", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StatusStringResponse(404, "Not Found"), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: heredoc.Doc(` + TITLE ID KEY TYPE ADDED + Mac 1234 ssh-rsa AAAABbBB123 signing 1d + `), + wantStderr: heredoc.Doc(` + warning: HTTP 404 (https://api.github.com/user/keys?per_page=100) + `), + wantErr: false, + isTTY: true, + }, { name: "no keys tty", opts: ListOptions{ @@ -97,6 +189,10 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/keys"), httpmock.StringResponse(`[]`), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[]`), + ) return &http.Client{Transport: reg}, nil }, }, @@ -114,6 +210,10 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/keys"), httpmock.StringResponse(`[]`), ) + reg.Register( + httpmock.REST("GET", "user/ssh_signing_keys"), + httpmock.StringResponse(`[]`), + ) return &http.Client{Transport: reg}, nil }, }, diff --git a/pkg/cmd/ssh-key/shared/user_keys.go b/pkg/cmd/ssh-key/shared/user_keys.go index 57f309d19..035d00244 100644 --- a/pkg/cmd/ssh-key/shared/user_keys.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -11,10 +11,16 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" ) +const ( + AuthenticationKey = "authentication" + SigningKey = "signing" +) + type sshKey struct { ID int Key string Title string + Type string CreatedAt time.Time `json:"created_at"` } @@ -24,6 +30,41 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error resource = fmt.Sprintf("users/%s/keys", userHandle) } url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + + keys, err := getUserKeys(httpClient, url) + + if err != nil { + return nil, err + } + + for i := 0; i < len(keys); i++ { + keys[i].Type = AuthenticationKey + } + + return keys, nil +} + +func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { + resource := "user/ssh_signing_keys" + if userHandle != "" { + resource = fmt.Sprintf("users/%s/ssh_signing_keys", userHandle) + } + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100) + + keys, err := getUserKeys(httpClient, url) + + if err != nil { + return nil, err + } + + for i := 0; i < len(keys); i++ { + keys[i].Type = SigningKey + } + + return keys, nil +} + +func getUserKeys(httpClient *http.Client, url string) ([]sshKey, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err