Merge pull request #7270 from kousikmitra/feature/ssh-signing-key

Add option to add ssh singing key
This commit is contained in:
Nate Smith 2023-04-11 16:09:45 -07:00 committed by GitHub
commit fe5b142233
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 309 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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